@agent-relay/factory 0.1.56 → 0.1.58
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 +148 -2
- package/dist/cli/fleet.d.ts +13 -0
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +431 -103
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +1107 -348
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +67 -6
- package/dist/config/schema.js.map +1 -1
- package/dist/cost/index.d.ts +3 -0
- package/dist/cost/index.d.ts.map +1 -0
- package/dist/cost/index.js +3 -0
- package/dist/cost/index.js.map +1 -0
- package/dist/cost/ledger.d.ts +69 -0
- package/dist/cost/ledger.d.ts.map +1 -0
- package/dist/cost/ledger.js +162 -0
- package/dist/cost/ledger.js.map +1 -0
- package/dist/cost/pricing.d.ts +18 -0
- package/dist/cost/pricing.d.ts.map +1 -0
- package/dist/cost/pricing.js +54 -0
- package/dist/cost/pricing.js.map +1 -0
- package/dist/feature-guardian/conversation.d.ts +251 -0
- package/dist/feature-guardian/conversation.d.ts.map +1 -0
- package/dist/feature-guardian/conversation.js +1893 -0
- package/dist/feature-guardian/conversation.js.map +1 -0
- package/dist/feature-guardian/index.d.ts +2 -0
- package/dist/feature-guardian/index.d.ts.map +1 -0
- package/dist/feature-guardian/index.js +2 -0
- package/dist/feature-guardian/index.js.map +1 -0
- package/dist/fleet/internal-fleet-client.d.ts +3 -1
- package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
- package/dist/fleet/internal-fleet-client.js +81 -0
- package/dist/fleet/internal-fleet-client.js.map +1 -1
- package/dist/fleet/relay-fleet-client.d.ts +3 -1
- package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
- package/dist/fleet/relay-fleet-client.js +48 -0
- package/dist/fleet/relay-fleet-client.js.map +1 -1
- package/dist/github/index.d.ts +2 -0
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +1 -0
- package/dist/github/index.js.map +1 -1
- package/dist/github/routed-pr-babysitter.d.ts +48 -0
- package/dist/github/routed-pr-babysitter.d.ts.map +1 -0
- package/dist/github/routed-pr-babysitter.js +197 -0
- package/dist/github/routed-pr-babysitter.js.map +1 -0
- package/dist/hosted/index.d.ts +1 -1
- package/dist/hosted/index.d.ts.map +1 -1
- package/dist/hosted/index.js +1 -1
- package/dist/hosted/index.js.map +1 -1
- package/dist/hosted/orchestrator.d.ts +1 -0
- package/dist/hosted/orchestrator.d.ts.map +1 -1
- package/dist/hosted/orchestrator.js +190 -1
- package/dist/hosted/orchestrator.js.map +1 -1
- package/dist/hosted/types.d.ts +4 -0
- package/dist/hosted/types.d.ts.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/intake/index.d.ts +4 -0
- package/dist/intake/index.d.ts.map +1 -0
- package/dist/intake/index.js +4 -0
- package/dist/intake/index.js.map +1 -0
- package/dist/intake/notion-relay-claim.d.ts +32 -0
- package/dist/intake/notion-relay-claim.d.ts.map +1 -0
- package/dist/intake/notion-relay-claim.js +173 -0
- package/dist/intake/notion-relay-claim.js.map +1 -0
- package/dist/intake/notion-relay-contract.d.ts +33 -0
- package/dist/intake/notion-relay-contract.d.ts.map +1 -0
- package/dist/intake/notion-relay-contract.js +163 -0
- package/dist/intake/notion-relay-contract.js.map +1 -0
- package/dist/intake/notion.d.ts +375 -0
- package/dist/intake/notion.d.ts.map +1 -0
- package/dist/intake/notion.js +773 -0
- package/dist/intake/notion.js.map +1 -0
- package/dist/mount/github-api-issue-read.d.ts +28 -0
- package/dist/mount/github-api-issue-read.d.ts.map +1 -0
- package/dist/mount/github-api-issue-read.js +163 -0
- package/dist/mount/github-api-issue-read.js.map +1 -0
- package/dist/mount/local-mount-preflight.d.ts +2 -0
- package/dist/mount/local-mount-preflight.d.ts.map +1 -1
- package/dist/mount/local-mount-preflight.js +59 -9
- package/dist/mount/local-mount-preflight.js.map +1 -1
- package/dist/mount/mount-auth-error.d.ts +41 -0
- package/dist/mount/mount-auth-error.d.ts.map +1 -0
- package/dist/mount/mount-auth-error.js +94 -0
- package/dist/mount/mount-auth-error.js.map +1 -0
- package/dist/mount/relayfile-binary.d.ts +5 -0
- package/dist/mount/relayfile-binary.d.ts.map +1 -1
- package/dist/mount/relayfile-binary.js +16 -2
- package/dist/mount/relayfile-binary.js.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.d.ts +18 -3
- package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.js +157 -13
- package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
- package/dist/mount/workspace-mirror.d.ts +13 -0
- package/dist/mount/workspace-mirror.d.ts.map +1 -0
- package/dist/mount/workspace-mirror.js +94 -0
- package/dist/mount/workspace-mirror.js.map +1 -0
- package/dist/observability/events.d.ts +715 -47
- package/dist/observability/events.d.ts.map +1 -1
- package/dist/observability/events.js +41 -0
- package/dist/observability/events.js.map +1 -1
- package/dist/orchestrator/factory.d.ts +51 -0
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +574 -50
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/ports/fleet.d.ts +11 -0
- package/dist/ports/fleet.d.ts.map +1 -1
- package/dist/ports/index.d.ts +2 -2
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/mount.d.ts +58 -1
- package/dist/ports/mount.d.ts.map +1 -1
- package/dist/ports/state.d.ts +42 -5
- package/dist/ports/state.d.ts.map +1 -1
- package/dist/state/file-state-store.d.ts +10 -1
- package/dist/state/file-state-store.d.ts.map +1 -1
- package/dist/state/file-state-store.js +91 -0
- package/dist/state/file-state-store.js.map +1 -1
- package/dist/state/in-memory-state-store.d.ts +10 -1
- package/dist/state/in-memory-state-store.d.ts.map +1 -1
- package/dist/state/in-memory-state-store.js +43 -0
- package/dist/state/in-memory-state-store.js.map +1 -1
- package/dist/telemetry.d.ts +9 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +8 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/testing/fakes.d.ts +8 -1
- package/dist/testing/fakes.d.ts.map +1 -1
- package/dist/testing/fakes.js +18 -0
- package/dist/testing/fakes.js.map +1 -1
- package/dist/triage/schema.d.ts +175 -42
- package/dist/triage/schema.d.ts.map +1 -1
- package/dist/triage/schema.js +19 -0
- package/dist/triage/schema.js.map +1 -1
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/writeback/github.d.ts.map +1 -1
- package/dist/writeback/github.js +39 -0
- package/dist/writeback/github.js.map +1 -1
- package/package.json +15 -2
|
@@ -27,6 +27,7 @@ import { CoalescedTaskQueue } from './coalesced-task-queue.js';
|
|
|
27
27
|
import { findAgentProcessByName, readProcessIdentity } from './process-identity.js';
|
|
28
28
|
import { readFactoryInFlightRegistry, terminatePids } from './reaper.js';
|
|
29
29
|
import { createFactoryCloudEventV1, factoryCloudReleaseReasonV1, } from '../observability/events.js';
|
|
30
|
+
import { boundedRunCostTotal, CostLedger } from '../cost/ledger.js';
|
|
30
31
|
class ClarificationWakeLeaseLostError extends Error {
|
|
31
32
|
}
|
|
32
33
|
class ClarificationQuestionDeliveryLeaseLostError extends Error {
|
|
@@ -131,6 +132,44 @@ const STALE_LOCAL_AGENT_RECLAIM_MAX_ATTEMPTS = 3;
|
|
|
131
132
|
const STALE_LOCAL_AGENT_RECLAIM_BACKOFF_MS = 500;
|
|
132
133
|
export const DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH = '/tmp/factory-run/factory-loop-heartbeat.json';
|
|
133
134
|
export const DEFAULT_FACTORY_LOOP_REGISTRY_PATH = '/tmp/factory-run/factory-loop-registry.json';
|
|
135
|
+
// #githubApiFallbackIssues is append-only in memory for the life of the
|
|
136
|
+
// process; bound it so a long-running `factory start --mode live` daemon
|
|
137
|
+
// doesn't accumulate one entry per fallback dispatch forever.
|
|
138
|
+
const GITHUB_API_FALLBACK_ISSUES_MAX = 2_000;
|
|
139
|
+
// A small, separately-bounded record of recently evicted identities, so a
|
|
140
|
+
// later miss can be reported as "eligibility was evicted" rather than
|
|
141
|
+
// silently folded into the same signal as "never was eligible" — an
|
|
142
|
+
// operator investigating a false phantom-skip needs to tell those apart.
|
|
143
|
+
const GITHUB_API_FALLBACK_ISSUES_EVICTED_MAX = 200;
|
|
144
|
+
/**
|
|
145
|
+
* Adds `identity` to `eligible`, evicting the least-recently-registered
|
|
146
|
+
* entry once at capacity (Sets preserve insertion order; delete+add
|
|
147
|
+
* refreshes an existing identity's recency instead of leaving it at its
|
|
148
|
+
* original position). An evicted identity moves into `evicted` — itself
|
|
149
|
+
* bounded — so a later caller can tell "eligibility was evicted" apart from
|
|
150
|
+
* "never was eligible" instead of collapsing both into the same signal.
|
|
151
|
+
* Exported standalone (pure, no class state) so the bounding and eviction
|
|
152
|
+
* behavior is directly testable without driving thousands of dispatches
|
|
153
|
+
* through a full FactoryLoop to fill the production-sized set.
|
|
154
|
+
*/
|
|
155
|
+
export function rememberBoundedFallbackEligibility(eligible, evicted, identity, maxEligible, maxEvicted) {
|
|
156
|
+
evicted.delete(identity);
|
|
157
|
+
eligible.delete(identity);
|
|
158
|
+
eligible.add(identity);
|
|
159
|
+
if (eligible.size <= maxEligible)
|
|
160
|
+
return;
|
|
161
|
+
const oldest = eligible.values().next().value;
|
|
162
|
+
if (oldest === undefined)
|
|
163
|
+
return;
|
|
164
|
+
eligible.delete(oldest);
|
|
165
|
+
evicted.delete(oldest);
|
|
166
|
+
evicted.add(oldest);
|
|
167
|
+
if (evicted.size <= maxEvicted)
|
|
168
|
+
return;
|
|
169
|
+
const oldestEvicted = evicted.values().next().value;
|
|
170
|
+
if (oldestEvicted !== undefined)
|
|
171
|
+
evicted.delete(oldestEvicted);
|
|
172
|
+
}
|
|
134
173
|
class DispatchLifecycleCapacityError extends Error {
|
|
135
174
|
}
|
|
136
175
|
class DispatchLifecycleOwnedElsewhereError extends Error {
|
|
@@ -180,6 +219,7 @@ export class FactoryLoop {
|
|
|
180
219
|
#relayflows;
|
|
181
220
|
#worktrees;
|
|
182
221
|
#reporter;
|
|
222
|
+
#costLedger;
|
|
183
223
|
#batchView;
|
|
184
224
|
#batchReady;
|
|
185
225
|
#listeners = new Map();
|
|
@@ -197,6 +237,8 @@ export class FactoryLoop {
|
|
|
197
237
|
#githubIssueAuthors = new Map();
|
|
198
238
|
#githubIssueAuthorLookups = new Map();
|
|
199
239
|
#githubIssuePreferredPaths = new Map();
|
|
240
|
+
#githubApiFallbackIssues = new Set();
|
|
241
|
+
#githubApiFallbackIssuesEvicted = new Set();
|
|
200
242
|
#githubIssuePathIndexReady = false;
|
|
201
243
|
#slackReporterUserIds = new Map();
|
|
202
244
|
#slackReporterUserIdLookups = new Map();
|
|
@@ -249,6 +291,10 @@ export class FactoryLoop {
|
|
|
249
291
|
#subscription;
|
|
250
292
|
#livePollTimer;
|
|
251
293
|
#livePollInFlight = false;
|
|
294
|
+
// The effective transport includes per-start overrides, which can differ
|
|
295
|
+
// from config.liveSubscription. Persist it so status/heartbeat describes
|
|
296
|
+
// the listener that was actually registered.
|
|
297
|
+
#liveTransport;
|
|
252
298
|
#liveEventCursor;
|
|
253
299
|
#liveEventHighWatermark;
|
|
254
300
|
#liveConnectStartedAtMs = 0;
|
|
@@ -275,6 +321,9 @@ export class FactoryLoop {
|
|
|
275
321
|
#reconciledAgentExitsActive = 0;
|
|
276
322
|
#reconciledAgentExitWaiters = [];
|
|
277
323
|
#agentLifecycleSignalsInFlight = new Map();
|
|
324
|
+
#agentUsageInFlight = new Set();
|
|
325
|
+
#dispatchLifecyclePersistenceSerial = new Map();
|
|
326
|
+
#agentUsageGroups = new Set();
|
|
278
327
|
#startupAgentAdoptionActive = false;
|
|
279
328
|
#startupRosterExitSignals;
|
|
280
329
|
// Composite issue + PR identities for which a babysitter has already been spawned, so repeated
|
|
@@ -313,6 +362,8 @@ export class FactoryLoop {
|
|
|
313
362
|
#offDeliveryFailed;
|
|
314
363
|
#offAgentMessage;
|
|
315
364
|
#offAgentLifecycleSignal;
|
|
365
|
+
#offAgentUsage;
|
|
366
|
+
#offUnpricedModel;
|
|
316
367
|
// undefined = readiness not yet probed; resolved lazily so the standalone
|
|
317
368
|
// runOnce() path (which skips #start) still ingests when the mount is present.
|
|
318
369
|
#githubIngestionEnabled;
|
|
@@ -375,6 +426,10 @@ export class FactoryLoop {
|
|
|
375
426
|
this.#relayflows = ports.relayflows;
|
|
376
427
|
this.#worktrees = ports.worktrees;
|
|
377
428
|
this.#reporter = ports.reporter;
|
|
429
|
+
this.#costLedger = ports.costLedger ?? new CostLedger();
|
|
430
|
+
this.#offUnpricedModel = this.#costLedger.onUnpricedModel((record) => {
|
|
431
|
+
void this.#reportUnpricedModel(record);
|
|
432
|
+
});
|
|
378
433
|
this.#state = ports.stateStore ?? new InMemoryStateStore({
|
|
379
434
|
batchSize: config.batchSize,
|
|
380
435
|
agentQuestionDedupeLimit: AGENT_QUESTION_DEDUPE_LIMIT,
|
|
@@ -611,6 +666,11 @@ export class FactoryLoop {
|
|
|
611
666
|
this.#schedulePreviewSweep();
|
|
612
667
|
try {
|
|
613
668
|
await this.#startLiveSubscription(issueSource, opts.liveSubscription);
|
|
669
|
+
// The initial live heartbeat is intentionally written before startup
|
|
670
|
+
// so crash reapers can see a daemon while it bootstraps. Write again
|
|
671
|
+
// once the listener is actually registered so external `factory
|
|
672
|
+
// status` can distinguish a quiet feed from no listener.
|
|
673
|
+
await this.#writeLiveHeartbeat('running');
|
|
614
674
|
await this.#rearmSlackReplyWatchers();
|
|
615
675
|
await this.#drainReadyClarificationWake();
|
|
616
676
|
await this.#rearmGithubIssueCommentWatchers();
|
|
@@ -736,12 +796,17 @@ export class FactoryLoop {
|
|
|
736
796
|
this.#offDeliveryFailed?.();
|
|
737
797
|
this.#offAgentMessage?.();
|
|
738
798
|
this.#offAgentLifecycleSignal?.();
|
|
799
|
+
this.#offAgentUsage?.();
|
|
800
|
+
this.#offUnpricedModel?.();
|
|
739
801
|
await Promise.allSettled([...this.#agentLifecycleSignalsInFlight.values()]);
|
|
802
|
+
await this.#drainAgentUsage();
|
|
740
803
|
await this.#slackConversationTurns.stop();
|
|
741
804
|
this.#offAgentExit = undefined;
|
|
742
805
|
this.#offDeliveryFailed = undefined;
|
|
743
806
|
this.#offAgentMessage = undefined;
|
|
744
807
|
this.#offAgentLifecycleSignal = undefined;
|
|
808
|
+
this.#offAgentUsage = undefined;
|
|
809
|
+
this.#offUnpricedModel = undefined;
|
|
745
810
|
await this.#fleet.dispose();
|
|
746
811
|
}
|
|
747
812
|
finally {
|
|
@@ -809,6 +874,7 @@ export class FactoryLoop {
|
|
|
809
874
|
}
|
|
810
875
|
async #startLiveSubscription(issueSource, overrides = {}) {
|
|
811
876
|
const options = this.#liveOptions(overrides);
|
|
877
|
+
this.#liveTransport = options.transport;
|
|
812
878
|
this.#liveConnectStartedAtMs = this.#clock.now();
|
|
813
879
|
this.#liveReplaySkewMarginMs = options.replaySkewMarginMs;
|
|
814
880
|
const highWatermark = await this.#currentEventHighWatermark();
|
|
@@ -2051,7 +2117,10 @@ export class FactoryLoop {
|
|
|
2051
2117
|
const dispatched = this.#dispatchUnlocked(decision, opts);
|
|
2052
2118
|
this.#dispatchInFlight.set(key, dispatched);
|
|
2053
2119
|
try {
|
|
2054
|
-
|
|
2120
|
+
const result = await dispatched;
|
|
2121
|
+
if (decision.issueResolution)
|
|
2122
|
+
result.issueResolution = structuredClone(decision.issueResolution);
|
|
2123
|
+
return result;
|
|
2055
2124
|
}
|
|
2056
2125
|
finally {
|
|
2057
2126
|
if (this.#dispatchInFlight.get(key) === dispatched) {
|
|
@@ -2085,7 +2154,11 @@ export class FactoryLoop {
|
|
|
2085
2154
|
this.#error(error, decision.issue);
|
|
2086
2155
|
throw error;
|
|
2087
2156
|
}
|
|
2088
|
-
|
|
2157
|
+
// This read runs before `decision` has any tracked record for
|
|
2158
|
+
// #deriveGithubApiFallbackEligibility to find (batch insertion happens
|
|
2159
|
+
// later, once scope/readiness are validated), so pass it directly
|
|
2160
|
+
// rather than registering it into the cache first.
|
|
2161
|
+
const liveIssue = await this.#readIssue(decision.issue.path, decision);
|
|
2089
2162
|
if (!liveIssue || !isInFactoryScope(liveIssue, this.#config.safety)) {
|
|
2090
2163
|
const error = new Error(`Refusing to dispatch ${decision.issue.key}: not factory-e2e scope`);
|
|
2091
2164
|
this.#error(error, decision.issue);
|
|
@@ -2440,8 +2513,27 @@ export class FactoryLoop {
|
|
|
2440
2513
|
counters: { ...this.#counters },
|
|
2441
2514
|
slackDegraded: this.#slackDegraded,
|
|
2442
2515
|
slackDegradedReason: this.#slackDegradedReason,
|
|
2516
|
+
eventListener: this.#eventListenerStatus(),
|
|
2443
2517
|
};
|
|
2444
2518
|
}
|
|
2519
|
+
#eventListenerStatus() {
|
|
2520
|
+
if (this.#startMode !== 'live') {
|
|
2521
|
+
return {
|
|
2522
|
+
state: 'not-listening',
|
|
2523
|
+
reason: this.#startMode ? `factory mode is ${this.#startMode}` : 'factory has not started',
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
if (!this.#liveHeartbeatActive) {
|
|
2527
|
+
return { state: 'not-listening', reason: 'live daemon heartbeat is inactive' };
|
|
2528
|
+
}
|
|
2529
|
+
if (this.#subscription) {
|
|
2530
|
+
return { state: 'subscribed' };
|
|
2531
|
+
}
|
|
2532
|
+
if (this.#liveTransport === 'poll') {
|
|
2533
|
+
return { state: 'polling' };
|
|
2534
|
+
}
|
|
2535
|
+
return { state: 'starting' };
|
|
2536
|
+
}
|
|
2445
2537
|
on(event, listener) {
|
|
2446
2538
|
let listeners = this.#listeners.get(event);
|
|
2447
2539
|
if (!listeners) {
|
|
@@ -2489,6 +2581,9 @@ export class FactoryLoop {
|
|
|
2489
2581
|
return handling;
|
|
2490
2582
|
});
|
|
2491
2583
|
}
|
|
2584
|
+
if (!this.#offAgentUsage) {
|
|
2585
|
+
this.#offAgentUsage = this.#fleet.onAgentUsage?.((usage) => this.#queueAgentUsage(usage));
|
|
2586
|
+
}
|
|
2492
2587
|
}
|
|
2493
2588
|
#queueAgentExit(name, reason) {
|
|
2494
2589
|
const startupSignals = this.#startupRosterExitSignals;
|
|
@@ -2554,6 +2649,7 @@ export class FactoryLoop {
|
|
|
2554
2649
|
continue;
|
|
2555
2650
|
}
|
|
2556
2651
|
this.#dispatchLifecycleEpochs.set(claim.key ?? key, claim.lease.epoch);
|
|
2652
|
+
this.#hydrateCostLedger(claim.lifecycle);
|
|
2557
2653
|
if (claim.lifecycle.phase === 'waiting-for-human')
|
|
2558
2654
|
continue;
|
|
2559
2655
|
const durableRecord = inFlightRecordFromLifecycle(claim.lifecycle);
|
|
@@ -2844,6 +2940,7 @@ export class FactoryLoop {
|
|
|
2844
2940
|
throw new Error(`Refusing to dispatch ${decision.issue.key}: ${reason}`);
|
|
2845
2941
|
}
|
|
2846
2942
|
this.#dispatchLifecycleEpochs.set(claim.key ?? key, claim.lease.epoch);
|
|
2943
|
+
this.#hydrateCostLedger(claim.lifecycle);
|
|
2847
2944
|
this.#scheduleDispatchLifecycleRenewal();
|
|
2848
2945
|
if (claim.created) {
|
|
2849
2946
|
await this.#reportLifecycle(claim.lifecycle, 'run.started');
|
|
@@ -2924,48 +3021,296 @@ export class FactoryLoop {
|
|
|
2924
3021
|
},
|
|
2925
3022
|
});
|
|
2926
3023
|
}
|
|
2927
|
-
async #
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
3024
|
+
async #handleAgentUsage(usage, key) {
|
|
3025
|
+
const record = (await this.#batch()).getIssueByAgent(usage.name);
|
|
3026
|
+
const activeRecord = record && issueKey(record.issue) === key ? record : undefined;
|
|
3027
|
+
let tracked = activeRecord?.agents.get(usage.name);
|
|
3028
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
3029
|
+
// Completion removes the in-memory batch record immediately after the
|
|
3030
|
+
// terminal lifecycle write. A broker usage snapshot can arrive in that
|
|
3031
|
+
// narrow window; consult durable state so the drop is observable instead
|
|
3032
|
+
// of silently looking like an unknown agent.
|
|
3033
|
+
if (!lifecycle)
|
|
3034
|
+
return;
|
|
3035
|
+
tracked ??= lifecycle.agents.find((agent) => agent.name === usage.name)?.tracked;
|
|
3036
|
+
if (!tracked)
|
|
3037
|
+
return;
|
|
3038
|
+
if (isTerminalDispatchLifecycle(lifecycle)) {
|
|
3039
|
+
await this.#noteDroppedTerminalCostUsage(lifecycle, tracked, usage);
|
|
3040
|
+
return;
|
|
3041
|
+
}
|
|
3042
|
+
const lifecycleRecord = activeRecord ?? inFlightRecordFromLifecycle(lifecycle);
|
|
2931
3043
|
const epoch = this.#dispatchLifecycleEpochs.get(key);
|
|
2932
3044
|
if (epoch === undefined) {
|
|
2933
|
-
this.#
|
|
2934
|
-
|
|
3045
|
+
this.#increment('costUsagePersistenceUnavailable');
|
|
3046
|
+
this.#logger.warn?.('[factory] unable to persist agent token usage without a durable lifecycle lease', {
|
|
3047
|
+
agentName: usage.name,
|
|
3048
|
+
issue: lifecycle.issue.key,
|
|
3049
|
+
});
|
|
3050
|
+
await this.#reportLifecycle(lifecycle, 'factory.anomaly', {
|
|
3051
|
+
level: 'error',
|
|
3052
|
+
errorCode: 'cost_usage_lease_unavailable',
|
|
3053
|
+
});
|
|
3054
|
+
return;
|
|
2935
3055
|
}
|
|
2936
|
-
const
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
if (releasedAgentNames.has(agent.name))
|
|
2945
|
-
agent.releasedAtMs ??= this.#clock.now();
|
|
3056
|
+
const lifecycleAgent = lifecycle.agents.find((agent) => agent.name === usage.name);
|
|
3057
|
+
if (!lifecycleAgent) {
|
|
3058
|
+
this.#increment('costUsageLifecycleAgentMissing');
|
|
3059
|
+
this.#logger.warn?.('[factory] unable to persist agent token usage for an untracked durable agent', {
|
|
3060
|
+
agentName: usage.name,
|
|
3061
|
+
issue: lifecycle.issue.key,
|
|
3062
|
+
});
|
|
3063
|
+
return;
|
|
2946
3064
|
}
|
|
2947
|
-
const
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
3065
|
+
const model = usage.model ?? tracked.spec.model ?? 'unknown';
|
|
3066
|
+
const groupId = costUsageGroupId(lifecycle.runId, tracked);
|
|
3067
|
+
this.#costLedger.record({
|
|
3068
|
+
runId: lifecycle.runId,
|
|
3069
|
+
role: tracked.spec.role,
|
|
3070
|
+
model,
|
|
3071
|
+
inputTokens: usage.inputTokens,
|
|
3072
|
+
outputTokens: usage.outputTokens,
|
|
3073
|
+
}, { entryId: costEntryId(groupId, model) });
|
|
3074
|
+
const nextUsage = mergeDispatchLifecycleAgentUsage(lifecycleAgent.costUsage, {
|
|
3075
|
+
model,
|
|
3076
|
+
inputTokens: usage.inputTokens,
|
|
3077
|
+
outputTokens: usage.outputTokens,
|
|
3078
|
+
});
|
|
3079
|
+
const nextLifecycle = {
|
|
3080
|
+
...lifecycle,
|
|
3081
|
+
agents: lifecycle.agents.map((agent) => agent.name === usage.name
|
|
3082
|
+
? { ...agent, costUsage: nextUsage }
|
|
3083
|
+
: agent),
|
|
3084
|
+
cost: boundedRunCostTotal(this.#costLedger.getRunTotal(lifecycle.runId)),
|
|
3085
|
+
updatedAtMs: this.#clock.now(),
|
|
3086
|
+
};
|
|
3087
|
+
let saved;
|
|
3088
|
+
try {
|
|
3089
|
+
saved = await this.#state.saveDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now(), nextLifecycle);
|
|
3090
|
+
}
|
|
3091
|
+
catch (error) {
|
|
2951
3092
|
await this.#reportLifecycle(lifecycle, 'factory.anomaly', {
|
|
2952
3093
|
level: 'error',
|
|
2953
|
-
errorCode: '
|
|
3094
|
+
errorCode: 'cost_usage_persistence_failed',
|
|
2954
3095
|
});
|
|
2955
|
-
|
|
2956
|
-
return false;
|
|
2957
|
-
}
|
|
2958
|
-
if (previous?.phase !== lifecycle.phase) {
|
|
2959
|
-
await this.#reportLifecycle(lifecycle, lifecycle.phase === 'complete'
|
|
2960
|
-
? 'run.succeeded'
|
|
2961
|
-
: lifecycle.phase === 'abandoned'
|
|
2962
|
-
? 'run.cancelled'
|
|
2963
|
-
: 'run.phase_changed', { previousPhase: previous?.phase, cancellationReason: telemetry.cancellationReason });
|
|
3096
|
+
throw error;
|
|
2964
3097
|
}
|
|
2965
|
-
if (
|
|
3098
|
+
if (!saved) {
|
|
2966
3099
|
this.#dispatchLifecycleEpochs.delete(key);
|
|
3100
|
+
const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
3101
|
+
if (latest && isTerminalDispatchLifecycle(latest)) {
|
|
3102
|
+
await this.#noteDroppedTerminalCostUsage(latest, tracked, usage);
|
|
3103
|
+
}
|
|
3104
|
+
else {
|
|
3105
|
+
this.#increment('costUsagePersistenceFailures');
|
|
3106
|
+
this.#logger.warn?.('[factory] unable to persist agent token usage', {
|
|
3107
|
+
agentName: usage.name,
|
|
3108
|
+
issue: lifecycle.issue.key,
|
|
3109
|
+
reason: 'fence_rejected',
|
|
3110
|
+
});
|
|
3111
|
+
await this.#reportLifecycle(lifecycle, 'factory.anomaly', {
|
|
3112
|
+
level: 'error',
|
|
3113
|
+
errorCode: 'cost_usage_persistence_failed',
|
|
3114
|
+
});
|
|
3115
|
+
this.#scheduleDispatchLifecycleRetry(lifecycleRecord);
|
|
3116
|
+
}
|
|
3117
|
+
return;
|
|
2967
3118
|
}
|
|
2968
|
-
|
|
3119
|
+
this.#agentUsageGroups.add(groupId);
|
|
3120
|
+
}
|
|
3121
|
+
async #findDispatchLifecycleAgent(name) {
|
|
3122
|
+
let match;
|
|
3123
|
+
for (const [key, lifecycle] of await this.#state.listDispatchLifecycles(this.#workspaceId)) {
|
|
3124
|
+
const agent = lifecycle.agents.find((candidate) => candidate.name === name);
|
|
3125
|
+
if (!agent || (match && match.lifecycle.updatedAtMs >= lifecycle.updatedAtMs))
|
|
3126
|
+
continue;
|
|
3127
|
+
match = { key, lifecycle, tracked: agent.tracked };
|
|
3128
|
+
}
|
|
3129
|
+
return match;
|
|
3130
|
+
}
|
|
3131
|
+
async #dispatchLifecycleKeyForAgent(name) {
|
|
3132
|
+
const record = (await this.#batch()).getIssueByAgent(name);
|
|
3133
|
+
if (record?.agents.has(name))
|
|
3134
|
+
return issueKey(record.issue);
|
|
3135
|
+
return (await this.#findDispatchLifecycleAgent(name))?.key;
|
|
3136
|
+
}
|
|
3137
|
+
#serializeDispatchLifecyclePersistence(key, operation) {
|
|
3138
|
+
const queued = (this.#dispatchLifecyclePersistenceSerial.get(key) ?? Promise.resolve())
|
|
3139
|
+
.catch(() => undefined)
|
|
3140
|
+
.then(operation);
|
|
3141
|
+
const settled = queued.then(() => undefined, () => undefined);
|
|
3142
|
+
this.#dispatchLifecyclePersistenceSerial.set(key, settled);
|
|
3143
|
+
void settled.then(() => {
|
|
3144
|
+
if (this.#dispatchLifecyclePersistenceSerial.get(key) === settled) {
|
|
3145
|
+
this.#dispatchLifecyclePersistenceSerial.delete(key);
|
|
3146
|
+
}
|
|
3147
|
+
});
|
|
3148
|
+
return queued;
|
|
3149
|
+
}
|
|
3150
|
+
#queueAgentUsage(usage) {
|
|
3151
|
+
// Usage reports are cumulative snapshots. Serializing their read/modify/
|
|
3152
|
+
// save cycle with phase changes prevents either mutation from replacing
|
|
3153
|
+
// the other's durable per-agent cost just before an owner restart.
|
|
3154
|
+
const handling = this.#dispatchLifecycleKeyForAgent(usage.name)
|
|
3155
|
+
.then((key) => {
|
|
3156
|
+
return key
|
|
3157
|
+
? this.#serializeDispatchLifecyclePersistence(key, () => this.#handleAgentUsage(usage, key))
|
|
3158
|
+
: undefined;
|
|
3159
|
+
})
|
|
3160
|
+
.catch(async (error) => {
|
|
3161
|
+
this.#increment('costUsageRecordingFailures');
|
|
3162
|
+
this.#logger.warn?.('[factory] unable to record agent token usage', {
|
|
3163
|
+
agentName: usage.name,
|
|
3164
|
+
errorClass: telemetryErrorClass(error),
|
|
3165
|
+
});
|
|
3166
|
+
})
|
|
3167
|
+
.finally(() => {
|
|
3168
|
+
this.#agentUsageInFlight.delete(handling);
|
|
3169
|
+
});
|
|
3170
|
+
this.#agentUsageInFlight.add(handling);
|
|
3171
|
+
return handling;
|
|
3172
|
+
}
|
|
3173
|
+
async #noteDroppedTerminalCostUsage(lifecycle, tracked, usage) {
|
|
3174
|
+
this.#increment('costUsageDroppedAfterTerminal');
|
|
3175
|
+
await this.#report({
|
|
3176
|
+
type: 'factory.anomaly',
|
|
3177
|
+
level: 'warn',
|
|
3178
|
+
runId: lifecycle.runId,
|
|
3179
|
+
attributes: {
|
|
3180
|
+
errorCode: 'cost_usage_after_terminal',
|
|
3181
|
+
agentRole: tracked.spec.role,
|
|
3182
|
+
model: usage.model ?? tracked.spec.model ?? 'unknown',
|
|
3183
|
+
inputTokens: usage.inputTokens,
|
|
3184
|
+
outputTokens: usage.outputTokens,
|
|
3185
|
+
},
|
|
3186
|
+
});
|
|
3187
|
+
}
|
|
3188
|
+
#hydrateCostLedger(lifecycle) {
|
|
3189
|
+
for (const agent of lifecycle.agents) {
|
|
3190
|
+
const usage = agent.costUsage ?? [];
|
|
3191
|
+
if (usage.length === 0)
|
|
3192
|
+
continue;
|
|
3193
|
+
const groupId = costUsageGroupId(lifecycle.runId, agent.tracked);
|
|
3194
|
+
for (const entry of usage) {
|
|
3195
|
+
this.#costLedger.record({
|
|
3196
|
+
runId: lifecycle.runId,
|
|
3197
|
+
role: agent.tracked.spec.role,
|
|
3198
|
+
model: entry.model,
|
|
3199
|
+
inputTokens: entry.inputTokens,
|
|
3200
|
+
outputTokens: entry.outputTokens,
|
|
3201
|
+
}, {
|
|
3202
|
+
entryId: costEntryId(groupId, entry.model),
|
|
3203
|
+
notifyUnpriced: false,
|
|
3204
|
+
});
|
|
3205
|
+
}
|
|
3206
|
+
this.#agentUsageGroups.add(groupId);
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
async #drainAgentUsage() {
|
|
3210
|
+
while (this.#agentUsageInFlight.size > 0) {
|
|
3211
|
+
await Promise.allSettled([...this.#agentUsageInFlight]);
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
async #reportUnpricedModel(record) {
|
|
3215
|
+
await this.#report({
|
|
3216
|
+
type: 'cost.model.unpriced',
|
|
3217
|
+
level: 'warn',
|
|
3218
|
+
runId: record.runId,
|
|
3219
|
+
attributes: {
|
|
3220
|
+
agentRole: record.role,
|
|
3221
|
+
model: record.model,
|
|
3222
|
+
inputTokens: record.inputTokens,
|
|
3223
|
+
outputTokens: record.outputTokens,
|
|
3224
|
+
usd: null,
|
|
3225
|
+
},
|
|
3226
|
+
});
|
|
3227
|
+
}
|
|
3228
|
+
#finalizeRunCost(record, runId) {
|
|
3229
|
+
for (const tracked of record.agents.values()) {
|
|
3230
|
+
const groupId = costUsageGroupId(runId, tracked);
|
|
3231
|
+
if (this.#agentUsageGroups.has(groupId))
|
|
3232
|
+
continue;
|
|
3233
|
+
this.#costLedger.record({
|
|
3234
|
+
runId,
|
|
3235
|
+
role: tracked.spec.role,
|
|
3236
|
+
model: tracked.spec.model ?? 'unknown',
|
|
3237
|
+
inputTokens: null,
|
|
3238
|
+
outputTokens: null,
|
|
3239
|
+
}, { entryId: costEntryId(groupId, 'missing') });
|
|
3240
|
+
}
|
|
3241
|
+
return boundedRunCostTotal(this.#costLedger.getRunTotal(runId));
|
|
3242
|
+
}
|
|
3243
|
+
async #reportRunCost(lifecycle) {
|
|
3244
|
+
if (!lifecycle.cost)
|
|
3245
|
+
return;
|
|
3246
|
+
const { runId: _runId, ...cost } = lifecycle.cost;
|
|
3247
|
+
await this.#report({
|
|
3248
|
+
type: 'run.cost.v1',
|
|
3249
|
+
level: 'info',
|
|
3250
|
+
runId: lifecycle.runId,
|
|
3251
|
+
status: telemetryRunStatus(lifecycle.phase),
|
|
3252
|
+
attributes: {
|
|
3253
|
+
inputTokens: lifecycle.cost.inputTokens,
|
|
3254
|
+
outputTokens: lifecycle.cost.outputTokens,
|
|
3255
|
+
usd: lifecycle.cost.usd,
|
|
3256
|
+
},
|
|
3257
|
+
cost,
|
|
3258
|
+
});
|
|
3259
|
+
}
|
|
3260
|
+
async #saveDispatchLifecycle(record, phase, pullRequest, releaseReason, releasedAgentNames = new Set(), telemetry = {}) {
|
|
3261
|
+
if (record.dryRun || !this.#usesDurableDispatchLifecycle())
|
|
3262
|
+
return true;
|
|
3263
|
+
if (isTerminalDispatchPhase(phase))
|
|
3264
|
+
await this.#drainAgentUsage();
|
|
3265
|
+
const key = issueKey(record.issue);
|
|
3266
|
+
return this.#serializeDispatchLifecyclePersistence(key, async () => {
|
|
3267
|
+
const epoch = this.#dispatchLifecycleEpochs.get(key);
|
|
3268
|
+
if (epoch === undefined) {
|
|
3269
|
+
this.#scheduleDispatchLifecycleRetry(record);
|
|
3270
|
+
return false;
|
|
3271
|
+
}
|
|
3272
|
+
const previous = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
3273
|
+
const pullRequests = mergePublishedPullRequests(previous, pullRequest);
|
|
3274
|
+
const primaryPullRequest = primaryPublishedPullRequest(previous, pullRequest, pullRequests);
|
|
3275
|
+
const lifecycleRunId = previous?.runId ?? randomUUID();
|
|
3276
|
+
const cost = isTerminalDispatchPhase(phase)
|
|
3277
|
+
? this.#finalizeRunCost(record, lifecycleRunId)
|
|
3278
|
+
: previous?.cost;
|
|
3279
|
+
const lifecycle = lifecycleFromInFlightRecord(record, lifecycleRunId, phase, this.#clock.now(), primaryPullRequest, pullRequests, releaseReason ?? previous?.releaseReason, cost);
|
|
3280
|
+
for (const agent of lifecycle.agents) {
|
|
3281
|
+
const previousAgent = previous?.agents.find((candidate) => candidate.name === agent.name);
|
|
3282
|
+
if (previousAgent?.releasedAtMs !== undefined)
|
|
3283
|
+
agent.releasedAtMs = previousAgent.releasedAtMs;
|
|
3284
|
+
if (previousAgent?.costUsage)
|
|
3285
|
+
agent.costUsage = structuredClone(previousAgent.costUsage);
|
|
3286
|
+
if (releasedAgentNames.has(agent.name))
|
|
3287
|
+
agent.releasedAtMs ??= this.#clock.now();
|
|
3288
|
+
}
|
|
3289
|
+
const saved = await this.#state.saveDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now(), lifecycle);
|
|
3290
|
+
if (!saved) {
|
|
3291
|
+
this.#dispatchLifecycleEpochs.delete(key);
|
|
3292
|
+
this.#increment('dispatchLifecycleFencesRejected');
|
|
3293
|
+
await this.#reportLifecycle(lifecycle, 'factory.anomaly', {
|
|
3294
|
+
level: 'error',
|
|
3295
|
+
errorCode: 'fence_rejected',
|
|
3296
|
+
});
|
|
3297
|
+
this.#scheduleDispatchLifecycleRetry(record);
|
|
3298
|
+
return false;
|
|
3299
|
+
}
|
|
3300
|
+
if (previous?.phase !== lifecycle.phase) {
|
|
3301
|
+
await this.#reportLifecycle(lifecycle, lifecycle.phase === 'complete'
|
|
3302
|
+
? 'run.succeeded'
|
|
3303
|
+
: lifecycle.phase === 'abandoned'
|
|
3304
|
+
? 'run.cancelled'
|
|
3305
|
+
: 'run.phase_changed', { previousPhase: previous?.phase, cancellationReason: telemetry.cancellationReason });
|
|
3306
|
+
if (isTerminalDispatchLifecycle(lifecycle))
|
|
3307
|
+
await this.#reportRunCost(lifecycle);
|
|
3308
|
+
}
|
|
3309
|
+
if (isTerminalDispatchLifecycle(lifecycle)) {
|
|
3310
|
+
this.#dispatchLifecycleEpochs.delete(key);
|
|
3311
|
+
}
|
|
3312
|
+
return true;
|
|
3313
|
+
});
|
|
2969
3314
|
}
|
|
2970
3315
|
#resolveDispatchTerminalWaiters(issue) {
|
|
2971
3316
|
const key = issueKey(issue);
|
|
@@ -3069,6 +3414,7 @@ export class FactoryLoop {
|
|
|
3069
3414
|
throw new DispatchLifecycleOwnedElsewhereError(claim.lifecycle.lease?.leaseUntilMs);
|
|
3070
3415
|
}
|
|
3071
3416
|
this.#dispatchLifecycleEpochs.set(claim.key ?? key, claim.lease.epoch);
|
|
3417
|
+
this.#hydrateCostLedger(claim.lifecycle);
|
|
3072
3418
|
this.#scheduleDispatchLifecycleRenewal();
|
|
3073
3419
|
lifecycle = claim.lifecycle;
|
|
3074
3420
|
acquiredNow = true;
|
|
@@ -3254,7 +3600,11 @@ export class FactoryLoop {
|
|
|
3254
3600
|
async #resumeDurableDispatch(record) {
|
|
3255
3601
|
let liveIssue;
|
|
3256
3602
|
if (!record.dryRun) {
|
|
3257
|
-
|
|
3603
|
+
// record is already inserted into the batch by the time any phase
|
|
3604
|
+
// handler runs (BatchTracker.restore, called before this), so
|
|
3605
|
+
// #deriveGithubApiFallbackEligibility would find it there too — the
|
|
3606
|
+
// explicit hint is defense in depth, not a requirement.
|
|
3607
|
+
liveIssue = await this.#readIssue(record.issue.path, record.decision);
|
|
3258
3608
|
if (!liveIssue) {
|
|
3259
3609
|
throw new Error(`Unable to recover durable dispatch ${record.issue.key}: issue is not currently readable`);
|
|
3260
3610
|
}
|
|
@@ -3856,7 +4206,62 @@ export class FactoryLoop {
|
|
|
3856
4206
|
this.#logger.error?.('[factory] failed to ingest GitHub issue', error);
|
|
3857
4207
|
}
|
|
3858
4208
|
}
|
|
3859
|
-
|
|
4209
|
+
#rememberGithubApiFallbackEligible(identity) {
|
|
4210
|
+
rememberBoundedFallbackEligibility(this.#githubApiFallbackIssues, this.#githubApiFallbackIssuesEvicted, identity, GITHUB_API_FALLBACK_ISSUES_MAX, GITHUB_API_FALLBACK_ISSUES_EVICTED_MAX);
|
|
4211
|
+
}
|
|
4212
|
+
/**
|
|
4213
|
+
* `#githubApiFallbackIssues` is a cache, not a source of truth: eligibility
|
|
4214
|
+
* is derived here, not remembered by every caller. A restart (or any read
|
|
4215
|
+
* path added later that never registered anything) still resolves
|
|
4216
|
+
* correctly because this checks the actual current state instead of
|
|
4217
|
+
* requiring every entry point to have called a registration method first.
|
|
4218
|
+
*
|
|
4219
|
+
* A decision can durably outlive the original dispatch call in three
|
|
4220
|
+
* shapes, and this checks all three — a fourth found later means this
|
|
4221
|
+
* enumeration is incomplete, not that the approach is wrong:
|
|
4222
|
+
* 1. `batch.inFlight` — an active live-dispatched or durably-restored
|
|
4223
|
+
* record (`BatchTracker.restore`/`start` insert it before any phase
|
|
4224
|
+
* handler runs).
|
|
4225
|
+
* 2. `#state.listWaitingClarifications` — an issue parked awaiting a
|
|
4226
|
+
* human reply. Restoring it (`#restoreClarifications` et al.)
|
|
4227
|
+
* rebuilds an `InFlightIssue`-shaped record locally to re-arm the
|
|
4228
|
+
* watcher and does not insert it into the batch, so (1) alone misses
|
|
4229
|
+
* it.
|
|
4230
|
+
* 3. `#state.listDispatchLifecycles`, non-terminal — a durably-tracked
|
|
4231
|
+
* dispatch that is not currently in-memory at all yet (e.g. a
|
|
4232
|
+
* non-durable-fleet dispatch never reaches the batch either).
|
|
4233
|
+
*
|
|
4234
|
+
* `decisionHint` stays a pure optimization ahead of all three: the one
|
|
4235
|
+
* read that happens before its own record exists anywhere (the initial
|
|
4236
|
+
* live dispatch, validating scope before it is tracked) can skip the
|
|
4237
|
+
* scan entirely, but no read site is required to pass it — omitting it
|
|
4238
|
+
* only costs the scan, never correctness.
|
|
4239
|
+
*
|
|
4240
|
+
* Deliberately scans by GitHub identity (owner/repo/number) against each
|
|
4241
|
+
* candidate's own issue path rather than looking up the durable record by
|
|
4242
|
+
* its composite key (uuid/key/path): the real uuid is built from GitHub's
|
|
4243
|
+
* node_id/id when content is available, which a bare path cannot
|
|
4244
|
+
* reconstruct, so a keyed lookup would not reliably match.
|
|
4245
|
+
*/
|
|
4246
|
+
async #deriveGithubApiFallbackEligibility(identity, decisionHint) {
|
|
4247
|
+
if (decisionHint?.issueResolution?.source === 'github-api-fallback' &&
|
|
4248
|
+
githubIssueRefIdentity(decisionHint.issue) === identity) {
|
|
4249
|
+
return true;
|
|
4250
|
+
}
|
|
4251
|
+
const inFlight = (await this.#batch()).inFlight;
|
|
4252
|
+
if (isGithubApiFallbackEligible(inFlight, identity))
|
|
4253
|
+
return true;
|
|
4254
|
+
const [waitingClarifications, dispatchLifecycles] = await Promise.all([
|
|
4255
|
+
this.#state.listWaitingClarifications(this.#workspaceId),
|
|
4256
|
+
this.#state.listDispatchLifecycles(this.#workspaceId),
|
|
4257
|
+
]);
|
|
4258
|
+
const durable = [
|
|
4259
|
+
...githubApiFallbackCandidatesFromWaitingClarifications(waitingClarifications),
|
|
4260
|
+
...githubApiFallbackCandidatesFromDispatchLifecycles(dispatchLifecycles),
|
|
4261
|
+
];
|
|
4262
|
+
return isGithubApiFallbackEligible(durable, identity);
|
|
4263
|
+
}
|
|
4264
|
+
async #readGithubIssue(path, decisionHint) {
|
|
3860
4265
|
const preferredPath = await this.#preferredGithubIssuePath(path);
|
|
3861
4266
|
const candidatePaths = [...new Set([
|
|
3862
4267
|
...githubIssueReadCandidatePaths(preferredPath),
|
|
@@ -3880,8 +4285,55 @@ export class FactoryLoop {
|
|
|
3880
4285
|
}
|
|
3881
4286
|
catch (error) {
|
|
3882
4287
|
if (isMissingIssueFileError(error)) {
|
|
4288
|
+
const parts = githubIssuePathParts(path) ?? githubIssueDirectoryPathParts(path);
|
|
4289
|
+
const identity = parts ? githubIssueIdentity(parts.owner, parts.repo, parts.number) : undefined;
|
|
4290
|
+
const eligible = identity
|
|
4291
|
+
? this.#githubApiFallbackIssues.has(identity) ||
|
|
4292
|
+
await this.#deriveGithubApiFallbackEligibility(identity, decisionHint)
|
|
4293
|
+
: false;
|
|
4294
|
+
if (eligible && identity)
|
|
4295
|
+
this.#rememberGithubApiFallbackEligible(identity);
|
|
4296
|
+
if (parts && identity && eligible && this.#mount.githubRead) {
|
|
4297
|
+
const lookup = await this.#mount.githubRead.getIssue(`${parts.owner}/${parts.repo}`, parts.number);
|
|
4298
|
+
if (lookup.outcome === 'found') {
|
|
4299
|
+
const githubIssue = parseGithubIssue(lookup.issue.path, lookup.issue.content);
|
|
4300
|
+
this.#indexDependencyIssue(githubIssueAsFactoryIssue(githubIssue));
|
|
4301
|
+
this.#logger.warn?.('[factory] Relayfile projection missed GitHub issue; using GitHub API fallback', {
|
|
4302
|
+
repo: lookup.issue.repo,
|
|
4303
|
+
number: lookup.issue.number,
|
|
4304
|
+
source: 'github-api-fallback',
|
|
4305
|
+
});
|
|
4306
|
+
return githubIssue;
|
|
4307
|
+
}
|
|
4308
|
+
if (lookup.outcome === 'indeterminate') {
|
|
4309
|
+
// The provider lookup could not confirm absence (e.g. an
|
|
4310
|
+
// unauthenticated 404 against a repo it cannot prove is public).
|
|
4311
|
+
// That is not the same claim as "confirmed gone" — count and log
|
|
4312
|
+
// it separately so it stays visible instead of being folded into
|
|
4313
|
+
// the phantom-skip metric.
|
|
4314
|
+
this.#increment('githubIssueUnverifiable');
|
|
4315
|
+
this.#logger.warn?.('[factory] GitHub API fallback could not determine issue existence', {
|
|
4316
|
+
path,
|
|
4317
|
+
repo: `${parts.owner}/${parts.repo}`,
|
|
4318
|
+
number: parts.number,
|
|
4319
|
+
reason: lookup.reason,
|
|
4320
|
+
});
|
|
4321
|
+
return undefined;
|
|
4322
|
+
}
|
|
4323
|
+
}
|
|
4324
|
+
if (identity && this.#githubApiFallbackIssuesEvicted.has(identity)) {
|
|
4325
|
+
// identity was eligible for the API fallback but aged out of the
|
|
4326
|
+
// bounded set — a different claim from "never was eligible", and
|
|
4327
|
+
// one this signal must not silently collapse into a confirmed miss.
|
|
4328
|
+
this.#increment('githubIssueApiFallbackEligibilityEvicted');
|
|
4329
|
+
this.#logger.warn?.('[factory] GitHub API fallback eligibility was evicted from the bounded cache before this read', {
|
|
4330
|
+
path,
|
|
4331
|
+
identity,
|
|
4332
|
+
});
|
|
4333
|
+
return undefined;
|
|
4334
|
+
}
|
|
3883
4335
|
this.#increment('githubIssuePhantomSkipped');
|
|
3884
|
-
this.#logger.debug?.('[factory] skipped missing GitHub issue
|
|
4336
|
+
this.#logger.debug?.('[factory] skipped missing GitHub issue after projection and provider lookup', { path });
|
|
3885
4337
|
return undefined;
|
|
3886
4338
|
}
|
|
3887
4339
|
throw error;
|
|
@@ -4299,6 +4751,7 @@ export class FactoryLoop {
|
|
|
4299
4751
|
updatedAt: new Date(updatedAtMs).toISOString(),
|
|
4300
4752
|
updatedAtMs,
|
|
4301
4753
|
registryPath,
|
|
4754
|
+
eventListener: this.#eventListenerStatus(),
|
|
4302
4755
|
};
|
|
4303
4756
|
await mkdir(dirname(path), { recursive: true });
|
|
4304
4757
|
await writeFile(path, `${JSON.stringify(heartbeat, null, 2)}\n`, 'utf8');
|
|
@@ -4466,10 +4919,10 @@ export class FactoryLoop {
|
|
|
4466
4919
|
}
|
|
4467
4920
|
return [...pathsByKey.values()].sort();
|
|
4468
4921
|
}
|
|
4469
|
-
async #readIssue(path) {
|
|
4922
|
+
async #readIssue(path, decisionHint) {
|
|
4470
4923
|
try {
|
|
4471
4924
|
if (isGithubIssueFilePath(path)) {
|
|
4472
|
-
const githubIssue = await this.#readGithubIssue(path);
|
|
4925
|
+
const githubIssue = await this.#readGithubIssue(path, decisionHint);
|
|
4473
4926
|
return githubIssue ? githubIssueAsFactoryIssue(githubIssue) : undefined;
|
|
4474
4927
|
}
|
|
4475
4928
|
// Newly-synced issues land as a change-event STUB at the primary
|
|
@@ -5334,6 +5787,7 @@ export class FactoryLoop {
|
|
|
5334
5787
|
baseRef,
|
|
5335
5788
|
title: `${issue.key}: ${issue.title}`,
|
|
5336
5789
|
body: githubPullRequestBody(issue, implementer.spec.preview),
|
|
5790
|
+
...(implementer.sessionRef ? { sessionRef: implementer.sessionRef } : {}),
|
|
5337
5791
|
});
|
|
5338
5792
|
const published = result.author
|
|
5339
5793
|
? result
|
|
@@ -5988,6 +6442,20 @@ export class FactoryLoop {
|
|
|
5988
6442
|
if (!tracked.sessionRef) {
|
|
5989
6443
|
return;
|
|
5990
6444
|
}
|
|
6445
|
+
// Never resume an agent against a read-denied mirror: the mount lacks the
|
|
6446
|
+
// filesystem scope it needs, so the spawn would fail opaquely (roster PID
|
|
6447
|
+
// never resolves) and risk operating on stale integration state. Skip with
|
|
6448
|
+
// one clear message; a later attempt succeeds once the session is re-authed
|
|
6449
|
+
// and Factory restarted.
|
|
6450
|
+
if (this.#mount.isLocalMountAuthDegraded?.()) {
|
|
6451
|
+
this.#increment('resumeSkippedMountAuthDegraded');
|
|
6452
|
+
this.#logger.warn?.('[factory] tracked agent resume skipped: local mount is auth-degraded (cloud session missing relayfile fs scope); re-authenticate and restart Factory', {
|
|
6453
|
+
issue: record.issue.key,
|
|
6454
|
+
name,
|
|
6455
|
+
role: tracked.spec.role,
|
|
6456
|
+
});
|
|
6457
|
+
return;
|
|
6458
|
+
}
|
|
5991
6459
|
this.#logger.debug?.('[factory] tracked agent resume preparation started', {
|
|
5992
6460
|
issue: record.issue.key,
|
|
5993
6461
|
name,
|
|
@@ -11133,7 +11601,7 @@ export class FactoryLoop {
|
|
|
11133
11601
|
}, Math.max(1_000, Math.floor(CLARIFICATION_WAKE_LEASE_MS / 3)));
|
|
11134
11602
|
heartbeat.unref?.();
|
|
11135
11603
|
try {
|
|
11136
|
-
if (!await this.#clarificationIssueStillActive(waiting.issue)) {
|
|
11604
|
+
if (!await this.#clarificationIssueStillActive(waiting.issue, waiting.decision)) {
|
|
11137
11605
|
this.#assertClarificationWakeRunning();
|
|
11138
11606
|
await renewLease();
|
|
11139
11607
|
const completed = await this.#state.completeClarificationWake(this.#workspaceId, key, this.#clarificationWakeOwner);
|
|
@@ -11309,8 +11777,8 @@ export class FactoryLoop {
|
|
|
11309
11777
|
if (this.#stopping)
|
|
11310
11778
|
throw new ClarificationWakeStoppedError('factory is stopping');
|
|
11311
11779
|
}
|
|
11312
|
-
async #clarificationIssueStillActive(issueRef) {
|
|
11313
|
-
const issue = await this.#readIssue(issueRef.path);
|
|
11780
|
+
async #clarificationIssueStillActive(issueRef, decisionHint) {
|
|
11781
|
+
const issue = await this.#readIssue(issueRef.path, decisionHint);
|
|
11314
11782
|
if (!issue || !isInFactoryScope(issue, this.#config.safety) || !isDispatchableIssue(issue)) {
|
|
11315
11783
|
this.#logger.info?.('[factory] clarification wake cancelled because issue left factory scope', {
|
|
11316
11784
|
issue: issueRef.key,
|
|
@@ -11476,12 +11944,11 @@ export class FactoryLoop {
|
|
|
11476
11944
|
this.#emit('issue-queued', { issue: decision.issue });
|
|
11477
11945
|
}
|
|
11478
11946
|
}
|
|
11479
|
-
// Absolute path to the
|
|
11480
|
-
//
|
|
11481
|
-
//
|
|
11482
|
-
// against this root rather than a bare relative `.integrations/...`.
|
|
11947
|
+
// Absolute path to the one registered workspace mirror. Spawned agents run in
|
|
11948
|
+
// repo clone paths, so writeback instructions must name the shared mirror,
|
|
11949
|
+
// not a relative `.integrations` path or a per-repository re-home attempt.
|
|
11483
11950
|
#integrationsMountRoot() {
|
|
11484
|
-
return resolve(process.cwd(), '.integrations');
|
|
11951
|
+
return this.#mount.getLocalMountRoot?.() ?? resolve(process.cwd(), '.integrations');
|
|
11485
11952
|
}
|
|
11486
11953
|
async #slackChannelDir() {
|
|
11487
11954
|
if (!this.#config.slack) {
|
|
@@ -11999,8 +12466,17 @@ const pidsFromSpawnResult = (result) => {
|
|
|
11999
12466
|
}
|
|
12000
12467
|
return [...pids].sort((a, b) => a - b);
|
|
12001
12468
|
};
|
|
12002
|
-
|
|
12469
|
+
// `issueResolution.detail` and `projection.localMountDegradedReason` are
|
|
12470
|
+
// free text that can carry an absolute local filesystem path (e.g. "mount
|
|
12471
|
+
// state is missing at /Users/<name>/...") — safe in the JSON result and in
|
|
12472
|
+
// logs, but this comment is posted to a public GitHub issue. Emit only
|
|
12473
|
+
// `source`, a closed enum, rather than trying to scrub paths out of free
|
|
12474
|
+
// text; free text is not a safe thing to scrub, only a safe thing to omit.
|
|
12475
|
+
export const dispatchComment = (decision, agents) => [
|
|
12003
12476
|
`Factory dispatch for ${decision.issue.key}`,
|
|
12477
|
+
decision.issueResolution
|
|
12478
|
+
? `Issue resolution: ${decision.issueResolution.source}`
|
|
12479
|
+
: undefined,
|
|
12004
12480
|
`Implementers: ${agents.filter((agent) => agent.role === 'implementer').map((agent) => agent.name).join(', ') || 'none'}`,
|
|
12005
12481
|
decision.scope === 'workflow' ? `Workflow: ${agents.find((agent) => agent.role === 'workflow')?.name ?? 'none'}` : undefined,
|
|
12006
12482
|
`Reviewer: ${agents.find((agent) => agent.role === 'reviewer')?.name ?? 'none'}`,
|
|
@@ -12518,11 +12994,51 @@ export const githubIssuePathParts = (path) => {
|
|
|
12518
12994
|
slug: match[7],
|
|
12519
12995
|
};
|
|
12520
12996
|
};
|
|
12521
|
-
const githubIssueIdentity = (owner, repo, number) => `${owner.toLowerCase()}/${repo.toLowerCase()}#${number}`;
|
|
12997
|
+
export const githubIssueIdentity = (owner, repo, number) => `${owner.toLowerCase()}/${repo.toLowerCase()}#${number}`;
|
|
12522
12998
|
const githubIssueRefIdentity = (issue) => {
|
|
12523
12999
|
const parts = githubIssuePathParts(issue.path);
|
|
12524
13000
|
return parts ? githubIssueIdentity(parts.owner, parts.repo, parts.number) : undefined;
|
|
12525
13001
|
};
|
|
13002
|
+
/**
|
|
13003
|
+
* Whether a GitHub API fallback read is eligible for `identity`, derived
|
|
13004
|
+
* from currently-tracked state rather than a separately-remembered flag:
|
|
13005
|
+
* `decisionHint` (the one read that happens before its own record is
|
|
13006
|
+
* tracked anywhere) or any `{issue, decision}` candidate — drawn from
|
|
13007
|
+
* whichever durable or in-memory store the caller has gathered — whose
|
|
13008
|
+
* decision was resolved through the fallback. Exported standalone so this
|
|
13009
|
+
* is directly testable — no caller has to have registered anything for
|
|
13010
|
+
* this to return true for a candidate that is genuinely present, which is
|
|
13011
|
+
* the property that keeps a future read call site from silently
|
|
13012
|
+
* reintroducing the restart-eligibility bug by omission.
|
|
13013
|
+
*
|
|
13014
|
+
* A decision durably outlives the in-memory batch in more than one shape
|
|
13015
|
+
* (in-flight dispatch, a parked clarification, a durable dispatch
|
|
13016
|
+
* lifecycle — see #deriveGithubApiFallbackEligibility for the full list
|
|
13017
|
+
* this class gathers); this function does not care which shape a
|
|
13018
|
+
* candidate came from, only whether one matches.
|
|
13019
|
+
*/
|
|
13020
|
+
export function isGithubApiFallbackEligible(candidates, identity, decisionHint) {
|
|
13021
|
+
if (decisionHint?.issueResolution?.source === 'github-api-fallback' &&
|
|
13022
|
+
githubIssueRefIdentity(decisionHint.issue) === identity) {
|
|
13023
|
+
return true;
|
|
13024
|
+
}
|
|
13025
|
+
return candidates.some((candidate) => candidate.decision.issueResolution?.source === 'github-api-fallback' &&
|
|
13026
|
+
githubIssueRefIdentity(candidate.issue) === identity);
|
|
13027
|
+
}
|
|
13028
|
+
/** Maps durable waiting-clarification records to isGithubApiFallbackEligible candidates. */
|
|
13029
|
+
export function githubApiFallbackCandidatesFromWaitingClarifications(waitingClarifications) {
|
|
13030
|
+
return waitingClarifications.map(([, waiting]) => ({ issue: waiting.issue, decision: waiting.decision }));
|
|
13031
|
+
}
|
|
13032
|
+
/**
|
|
13033
|
+
* Maps durable dispatch-lifecycle records to isGithubApiFallbackEligible
|
|
13034
|
+
* candidates, excluding terminal ones — a completed or abandoned dispatch's
|
|
13035
|
+
* decision is no longer a reason to trust a live read for that issue.
|
|
13036
|
+
*/
|
|
13037
|
+
export function githubApiFallbackCandidatesFromDispatchLifecycles(dispatchLifecycles) {
|
|
13038
|
+
return dispatchLifecycles
|
|
13039
|
+
.filter(([, lifecycle]) => !isTerminalDispatchLifecycle(lifecycle))
|
|
13040
|
+
.map(([, lifecycle]) => ({ issue: lifecycle.issue, decision: lifecycle.decision }));
|
|
13041
|
+
}
|
|
12526
13042
|
const githubIssuePathPreference = (path) => {
|
|
12527
13043
|
if (path.endsWith('/meta.json'))
|
|
12528
13044
|
return 0;
|
|
@@ -13833,6 +14349,9 @@ const durableBabysitterTrackedAgent = (session, capability = 'spawn:claude') =>
|
|
|
13833
14349
|
result: { name: session.agentName },
|
|
13834
14350
|
});
|
|
13835
14351
|
const isTerminalDispatchLifecycle = (lifecycle) => lifecycle.phase === 'complete' || lifecycle.phase === 'abandoned';
|
|
14352
|
+
const isTerminalDispatchPhase = (phase) => phase === 'complete' || phase === 'abandoned';
|
|
14353
|
+
const costUsageGroupId = (runId, tracked) => JSON.stringify([runId, tracked.spec.invocationId ?? tracked.spec.name]);
|
|
14354
|
+
const costEntryId = (groupId, model) => JSON.stringify([groupId, model]);
|
|
13836
14355
|
const publishedPullRequests = (lifecycle) => {
|
|
13837
14356
|
const receipts = [
|
|
13838
14357
|
...(lifecycle?.pullRequests ?? []).filter(Boolean),
|
|
@@ -13858,7 +14377,11 @@ const primaryPublishedPullRequest = (previous, receipt, receipts) => {
|
|
|
13858
14377
|
return receipt;
|
|
13859
14378
|
return previous?.pullRequest ?? receipt ?? receipts[0];
|
|
13860
14379
|
};
|
|
13861
|
-
const
|
|
14380
|
+
const mergeDispatchLifecycleAgentUsage = (current, next) => [
|
|
14381
|
+
...(current ?? []).filter((entry) => entry.model !== next.model),
|
|
14382
|
+
structuredClone(next),
|
|
14383
|
+
].sort((left, right) => left.model.localeCompare(right.model));
|
|
14384
|
+
const lifecycleFromInFlightRecord = (record, runId, phase, updatedAtMs, pullRequest, pullRequests = [], releaseReason, cost) => ({
|
|
13862
14385
|
runId,
|
|
13863
14386
|
issue: { ...record.issue },
|
|
13864
14387
|
decision: structuredClone(record.decision),
|
|
@@ -13870,6 +14393,7 @@ const lifecycleFromInFlightRecord = (record, runId, phase, updatedAtMs, pullRequ
|
|
|
13870
14393
|
...(pullRequests.length > 0 ? { pullRequests: pullRequests.map((receipt) => ({ ...receipt })) } : {}),
|
|
13871
14394
|
...(pullRequest ? { pullRequest: { ...pullRequest } } : {}),
|
|
13872
14395
|
...(releaseReason ? { releaseReason } : {}),
|
|
14396
|
+
...(cost ? { cost: structuredClone(cost) } : {}),
|
|
13873
14397
|
updatedAtMs,
|
|
13874
14398
|
});
|
|
13875
14399
|
const inFlightRecordFromLifecycle = (lifecycle) => ({
|