@agent-relay/factory 0.1.57 → 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 +139 -1
- 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/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/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/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- 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 +32 -32
- package/dist/orchestrator/factory.d.ts +51 -0
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +263 -17
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/ports/index.d.ts +1 -1
- 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 +21 -0
- 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/testing/fakes.d.ts +4 -0
- package/dist/testing/fakes.d.ts.map +1 -1
- package/dist/testing/fakes.js +6 -0
- package/dist/testing/fakes.js.map +1 -1
- package/dist/triage/schema.d.ts +133 -0
- 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 +30 -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 +10 -1
|
@@ -132,6 +132,44 @@ const STALE_LOCAL_AGENT_RECLAIM_MAX_ATTEMPTS = 3;
|
|
|
132
132
|
const STALE_LOCAL_AGENT_RECLAIM_BACKOFF_MS = 500;
|
|
133
133
|
export const DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH = '/tmp/factory-run/factory-loop-heartbeat.json';
|
|
134
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
|
+
}
|
|
135
173
|
class DispatchLifecycleCapacityError extends Error {
|
|
136
174
|
}
|
|
137
175
|
class DispatchLifecycleOwnedElsewhereError extends Error {
|
|
@@ -199,6 +237,8 @@ export class FactoryLoop {
|
|
|
199
237
|
#githubIssueAuthors = new Map();
|
|
200
238
|
#githubIssueAuthorLookups = new Map();
|
|
201
239
|
#githubIssuePreferredPaths = new Map();
|
|
240
|
+
#githubApiFallbackIssues = new Set();
|
|
241
|
+
#githubApiFallbackIssuesEvicted = new Set();
|
|
202
242
|
#githubIssuePathIndexReady = false;
|
|
203
243
|
#slackReporterUserIds = new Map();
|
|
204
244
|
#slackReporterUserIdLookups = new Map();
|
|
@@ -251,6 +291,10 @@ export class FactoryLoop {
|
|
|
251
291
|
#subscription;
|
|
252
292
|
#livePollTimer;
|
|
253
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;
|
|
254
298
|
#liveEventCursor;
|
|
255
299
|
#liveEventHighWatermark;
|
|
256
300
|
#liveConnectStartedAtMs = 0;
|
|
@@ -622,6 +666,11 @@ export class FactoryLoop {
|
|
|
622
666
|
this.#schedulePreviewSweep();
|
|
623
667
|
try {
|
|
624
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');
|
|
625
674
|
await this.#rearmSlackReplyWatchers();
|
|
626
675
|
await this.#drainReadyClarificationWake();
|
|
627
676
|
await this.#rearmGithubIssueCommentWatchers();
|
|
@@ -825,6 +874,7 @@ export class FactoryLoop {
|
|
|
825
874
|
}
|
|
826
875
|
async #startLiveSubscription(issueSource, overrides = {}) {
|
|
827
876
|
const options = this.#liveOptions(overrides);
|
|
877
|
+
this.#liveTransport = options.transport;
|
|
828
878
|
this.#liveConnectStartedAtMs = this.#clock.now();
|
|
829
879
|
this.#liveReplaySkewMarginMs = options.replaySkewMarginMs;
|
|
830
880
|
const highWatermark = await this.#currentEventHighWatermark();
|
|
@@ -2067,7 +2117,10 @@ export class FactoryLoop {
|
|
|
2067
2117
|
const dispatched = this.#dispatchUnlocked(decision, opts);
|
|
2068
2118
|
this.#dispatchInFlight.set(key, dispatched);
|
|
2069
2119
|
try {
|
|
2070
|
-
|
|
2120
|
+
const result = await dispatched;
|
|
2121
|
+
if (decision.issueResolution)
|
|
2122
|
+
result.issueResolution = structuredClone(decision.issueResolution);
|
|
2123
|
+
return result;
|
|
2071
2124
|
}
|
|
2072
2125
|
finally {
|
|
2073
2126
|
if (this.#dispatchInFlight.get(key) === dispatched) {
|
|
@@ -2101,7 +2154,11 @@ export class FactoryLoop {
|
|
|
2101
2154
|
this.#error(error, decision.issue);
|
|
2102
2155
|
throw error;
|
|
2103
2156
|
}
|
|
2104
|
-
|
|
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);
|
|
2105
2162
|
if (!liveIssue || !isInFactoryScope(liveIssue, this.#config.safety)) {
|
|
2106
2163
|
const error = new Error(`Refusing to dispatch ${decision.issue.key}: not factory-e2e scope`);
|
|
2107
2164
|
this.#error(error, decision.issue);
|
|
@@ -2456,8 +2513,27 @@ export class FactoryLoop {
|
|
|
2456
2513
|
counters: { ...this.#counters },
|
|
2457
2514
|
slackDegraded: this.#slackDegraded,
|
|
2458
2515
|
slackDegradedReason: this.#slackDegradedReason,
|
|
2516
|
+
eventListener: this.#eventListenerStatus(),
|
|
2459
2517
|
};
|
|
2460
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
|
+
}
|
|
2461
2537
|
on(event, listener) {
|
|
2462
2538
|
let listeners = this.#listeners.get(event);
|
|
2463
2539
|
if (!listeners) {
|
|
@@ -3524,7 +3600,11 @@ export class FactoryLoop {
|
|
|
3524
3600
|
async #resumeDurableDispatch(record) {
|
|
3525
3601
|
let liveIssue;
|
|
3526
3602
|
if (!record.dryRun) {
|
|
3527
|
-
|
|
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);
|
|
3528
3608
|
if (!liveIssue) {
|
|
3529
3609
|
throw new Error(`Unable to recover durable dispatch ${record.issue.key}: issue is not currently readable`);
|
|
3530
3610
|
}
|
|
@@ -4126,7 +4206,62 @@ export class FactoryLoop {
|
|
|
4126
4206
|
this.#logger.error?.('[factory] failed to ingest GitHub issue', error);
|
|
4127
4207
|
}
|
|
4128
4208
|
}
|
|
4129
|
-
|
|
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) {
|
|
4130
4265
|
const preferredPath = await this.#preferredGithubIssuePath(path);
|
|
4131
4266
|
const candidatePaths = [...new Set([
|
|
4132
4267
|
...githubIssueReadCandidatePaths(preferredPath),
|
|
@@ -4150,8 +4285,55 @@ export class FactoryLoop {
|
|
|
4150
4285
|
}
|
|
4151
4286
|
catch (error) {
|
|
4152
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
|
+
}
|
|
4153
4335
|
this.#increment('githubIssuePhantomSkipped');
|
|
4154
|
-
this.#logger.debug?.('[factory] skipped missing GitHub issue
|
|
4336
|
+
this.#logger.debug?.('[factory] skipped missing GitHub issue after projection and provider lookup', { path });
|
|
4155
4337
|
return undefined;
|
|
4156
4338
|
}
|
|
4157
4339
|
throw error;
|
|
@@ -4569,6 +4751,7 @@ export class FactoryLoop {
|
|
|
4569
4751
|
updatedAt: new Date(updatedAtMs).toISOString(),
|
|
4570
4752
|
updatedAtMs,
|
|
4571
4753
|
registryPath,
|
|
4754
|
+
eventListener: this.#eventListenerStatus(),
|
|
4572
4755
|
};
|
|
4573
4756
|
await mkdir(dirname(path), { recursive: true });
|
|
4574
4757
|
await writeFile(path, `${JSON.stringify(heartbeat, null, 2)}\n`, 'utf8');
|
|
@@ -4736,10 +4919,10 @@ export class FactoryLoop {
|
|
|
4736
4919
|
}
|
|
4737
4920
|
return [...pathsByKey.values()].sort();
|
|
4738
4921
|
}
|
|
4739
|
-
async #readIssue(path) {
|
|
4922
|
+
async #readIssue(path, decisionHint) {
|
|
4740
4923
|
try {
|
|
4741
4924
|
if (isGithubIssueFilePath(path)) {
|
|
4742
|
-
const githubIssue = await this.#readGithubIssue(path);
|
|
4925
|
+
const githubIssue = await this.#readGithubIssue(path, decisionHint);
|
|
4743
4926
|
return githubIssue ? githubIssueAsFactoryIssue(githubIssue) : undefined;
|
|
4744
4927
|
}
|
|
4745
4928
|
// Newly-synced issues land as a change-event STUB at the primary
|
|
@@ -5604,6 +5787,7 @@ export class FactoryLoop {
|
|
|
5604
5787
|
baseRef,
|
|
5605
5788
|
title: `${issue.key}: ${issue.title}`,
|
|
5606
5789
|
body: githubPullRequestBody(issue, implementer.spec.preview),
|
|
5790
|
+
...(implementer.sessionRef ? { sessionRef: implementer.sessionRef } : {}),
|
|
5607
5791
|
});
|
|
5608
5792
|
const published = result.author
|
|
5609
5793
|
? result
|
|
@@ -6258,6 +6442,20 @@ export class FactoryLoop {
|
|
|
6258
6442
|
if (!tracked.sessionRef) {
|
|
6259
6443
|
return;
|
|
6260
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
|
+
}
|
|
6261
6459
|
this.#logger.debug?.('[factory] tracked agent resume preparation started', {
|
|
6262
6460
|
issue: record.issue.key,
|
|
6263
6461
|
name,
|
|
@@ -11403,7 +11601,7 @@ export class FactoryLoop {
|
|
|
11403
11601
|
}, Math.max(1_000, Math.floor(CLARIFICATION_WAKE_LEASE_MS / 3)));
|
|
11404
11602
|
heartbeat.unref?.();
|
|
11405
11603
|
try {
|
|
11406
|
-
if (!await this.#clarificationIssueStillActive(waiting.issue)) {
|
|
11604
|
+
if (!await this.#clarificationIssueStillActive(waiting.issue, waiting.decision)) {
|
|
11407
11605
|
this.#assertClarificationWakeRunning();
|
|
11408
11606
|
await renewLease();
|
|
11409
11607
|
const completed = await this.#state.completeClarificationWake(this.#workspaceId, key, this.#clarificationWakeOwner);
|
|
@@ -11579,8 +11777,8 @@ export class FactoryLoop {
|
|
|
11579
11777
|
if (this.#stopping)
|
|
11580
11778
|
throw new ClarificationWakeStoppedError('factory is stopping');
|
|
11581
11779
|
}
|
|
11582
|
-
async #clarificationIssueStillActive(issueRef) {
|
|
11583
|
-
const issue = await this.#readIssue(issueRef.path);
|
|
11780
|
+
async #clarificationIssueStillActive(issueRef, decisionHint) {
|
|
11781
|
+
const issue = await this.#readIssue(issueRef.path, decisionHint);
|
|
11584
11782
|
if (!issue || !isInFactoryScope(issue, this.#config.safety) || !isDispatchableIssue(issue)) {
|
|
11585
11783
|
this.#logger.info?.('[factory] clarification wake cancelled because issue left factory scope', {
|
|
11586
11784
|
issue: issueRef.key,
|
|
@@ -11746,12 +11944,11 @@ export class FactoryLoop {
|
|
|
11746
11944
|
this.#emit('issue-queued', { issue: decision.issue });
|
|
11747
11945
|
}
|
|
11748
11946
|
}
|
|
11749
|
-
// Absolute path to the
|
|
11750
|
-
//
|
|
11751
|
-
//
|
|
11752
|
-
// 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.
|
|
11753
11950
|
#integrationsMountRoot() {
|
|
11754
|
-
return resolve(process.cwd(), '.integrations');
|
|
11951
|
+
return this.#mount.getLocalMountRoot?.() ?? resolve(process.cwd(), '.integrations');
|
|
11755
11952
|
}
|
|
11756
11953
|
async #slackChannelDir() {
|
|
11757
11954
|
if (!this.#config.slack) {
|
|
@@ -12269,8 +12466,17 @@ const pidsFromSpawnResult = (result) => {
|
|
|
12269
12466
|
}
|
|
12270
12467
|
return [...pids].sort((a, b) => a - b);
|
|
12271
12468
|
};
|
|
12272
|
-
|
|
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) => [
|
|
12273
12476
|
`Factory dispatch for ${decision.issue.key}`,
|
|
12477
|
+
decision.issueResolution
|
|
12478
|
+
? `Issue resolution: ${decision.issueResolution.source}`
|
|
12479
|
+
: undefined,
|
|
12274
12480
|
`Implementers: ${agents.filter((agent) => agent.role === 'implementer').map((agent) => agent.name).join(', ') || 'none'}`,
|
|
12275
12481
|
decision.scope === 'workflow' ? `Workflow: ${agents.find((agent) => agent.role === 'workflow')?.name ?? 'none'}` : undefined,
|
|
12276
12482
|
`Reviewer: ${agents.find((agent) => agent.role === 'reviewer')?.name ?? 'none'}`,
|
|
@@ -12788,11 +12994,51 @@ export const githubIssuePathParts = (path) => {
|
|
|
12788
12994
|
slug: match[7],
|
|
12789
12995
|
};
|
|
12790
12996
|
};
|
|
12791
|
-
const githubIssueIdentity = (owner, repo, number) => `${owner.toLowerCase()}/${repo.toLowerCase()}#${number}`;
|
|
12997
|
+
export const githubIssueIdentity = (owner, repo, number) => `${owner.toLowerCase()}/${repo.toLowerCase()}#${number}`;
|
|
12792
12998
|
const githubIssueRefIdentity = (issue) => {
|
|
12793
12999
|
const parts = githubIssuePathParts(issue.path);
|
|
12794
13000
|
return parts ? githubIssueIdentity(parts.owner, parts.repo, parts.number) : undefined;
|
|
12795
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
|
+
}
|
|
12796
13042
|
const githubIssuePathPreference = (path) => {
|
|
12797
13043
|
if (path.endsWith('/meta.json'))
|
|
12798
13044
|
return 0;
|