@agent-relay/factory 0.1.62 → 0.1.63
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 +117 -15
- package/dist/cli/exit-codes.d.ts +81 -0
- package/dist/cli/exit-codes.d.ts.map +1 -0
- package/dist/cli/exit-codes.js +122 -0
- package/dist/cli/exit-codes.js.map +1 -0
- package/dist/cli/fleet.d.ts +22 -2
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +152 -33
- package/dist/cli/fleet.js.map +1 -1
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/config/schema.d.ts +156 -52
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +17 -5
- package/dist/config/schema.js.map +1 -1
- package/dist/dispatch/templates.d.ts +7 -0
- package/dist/dispatch/templates.d.ts.map +1 -1
- package/dist/dispatch/templates.js +64 -12
- package/dist/dispatch/templates.js.map +1 -1
- package/dist/dispatch/work-unit-identity.d.ts +19 -0
- package/dist/dispatch/work-unit-identity.d.ts.map +1 -0
- package/dist/dispatch/work-unit-identity.js +40 -0
- package/dist/dispatch/work-unit-identity.js.map +1 -0
- package/dist/environments/stack-descriptor.d.ts +3 -3
- package/dist/fleet/control-plane-circuit.d.ts +57 -0
- package/dist/fleet/control-plane-circuit.d.ts.map +1 -0
- package/dist/fleet/control-plane-circuit.js +219 -0
- package/dist/fleet/control-plane-circuit.js.map +1 -0
- package/dist/fleet/internal-fleet-client.d.ts +2 -1
- package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
- package/dist/fleet/internal-fleet-client.js +11 -7
- package/dist/fleet/internal-fleet-client.js.map +1 -1
- package/dist/fleet/relay-fleet-client.d.ts +3 -0
- package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
- package/dist/fleet/relay-fleet-client.js +97 -18
- package/dist/fleet/relay-fleet-client.js.map +1 -1
- package/dist/github/writeback-paths.d.ts +7 -0
- package/dist/github/writeback-paths.d.ts.map +1 -0
- package/dist/github/writeback-paths.js +28 -0
- package/dist/github/writeback-paths.js.map +1 -0
- package/dist/hosted/orchestrator.d.ts.map +1 -1
- package/dist/hosted/orchestrator.js +2 -0
- package/dist/hosted/orchestrator.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/intake/index.d.ts +2 -1
- package/dist/intake/index.d.ts.map +1 -1
- package/dist/intake/index.js +2 -1
- package/dist/intake/index.js.map +1 -1
- package/dist/intake/notion-manifest.d.ts +48 -0
- package/dist/intake/notion-manifest.d.ts.map +1 -0
- package/dist/intake/notion-manifest.js +258 -0
- package/dist/intake/notion-manifest.js.map +1 -0
- package/dist/intake/notion-relay-claim.d.ts +5 -4
- package/dist/intake/notion-relay-claim.d.ts.map +1 -1
- package/dist/intake/notion-relay-claim.js +48 -7
- package/dist/intake/notion-relay-claim.js.map +1 -1
- package/dist/intake/notion.d.ts +6 -3
- package/dist/intake/notion.d.ts.map +1 -1
- package/dist/intake/notion.js +63 -13
- package/dist/intake/notion.js.map +1 -1
- package/dist/issue-key-match.d.ts +6 -0
- package/dist/issue-key-match.d.ts.map +1 -1
- package/dist/issue-key-match.js +6 -1
- package/dist/issue-key-match.js.map +1 -1
- package/dist/mount/local-mount-preflight.d.ts.map +1 -1
- package/dist/mount/local-mount-preflight.js +6 -2
- package/dist/mount/local-mount-preflight.js.map +1 -1
- package/dist/mount/mount-auth-error.d.ts +16 -3
- package/dist/mount/mount-auth-error.d.ts.map +1 -1
- package/dist/mount/mount-auth-error.js +34 -7
- package/dist/mount/mount-auth-error.js.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.d.ts +14 -1
- package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.js +107 -6
- package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
- package/dist/mount/relayfile-github-connection-write.d.ts +24 -2
- package/dist/mount/relayfile-github-connection-write.d.ts.map +1 -1
- package/dist/mount/relayfile-github-connection-write.js +90 -1
- package/dist/mount/relayfile-github-connection-write.js.map +1 -1
- package/dist/node/factory-node.d.ts.map +1 -1
- package/dist/node/factory-node.js +18 -4
- package/dist/node/factory-node.js.map +1 -1
- package/dist/observability/events.d.ts +52 -52
- package/dist/orchestrator/batch-tracker.d.ts +25 -0
- package/dist/orchestrator/batch-tracker.d.ts.map +1 -1
- package/dist/orchestrator/batch-tracker.js +48 -2
- package/dist/orchestrator/batch-tracker.js.map +1 -1
- package/dist/orchestrator/factory.d.ts +36 -2
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +383 -31
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/orchestrator/index.d.ts +1 -1
- package/dist/orchestrator/index.d.ts.map +1 -1
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/ports/fleet.d.ts +6 -0
- package/dist/ports/fleet.d.ts.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 +39 -0
- package/dist/ports/mount.d.ts.map +1 -1
- package/dist/ports/state.d.ts +15 -1
- package/dist/ports/state.d.ts.map +1 -1
- package/dist/state/document-store.d.ts +32 -0
- package/dist/state/document-store.d.ts.map +1 -0
- package/dist/state/document-store.js +7 -0
- package/dist/state/document-store.js.map +1 -0
- package/dist/state/file-state-store.d.ts +16 -2
- package/dist/state/file-state-store.d.ts.map +1 -1
- package/dist/state/file-state-store.js +45 -242
- package/dist/state/file-state-store.js.map +1 -1
- package/dist/state/watch-state-document.d.ts +5 -0
- package/dist/state/watch-state-document.d.ts.map +1 -0
- package/dist/state/watch-state-document.js +444 -0
- package/dist/state/watch-state-document.js.map +1 -0
- package/dist/testing/deadline.d.ts +11 -0
- package/dist/testing/deadline.d.ts.map +1 -0
- package/dist/testing/deadline.js +23 -0
- package/dist/testing/deadline.js.map +1 -0
- package/dist/testing/fakes.d.ts +2 -0
- package/dist/testing/fakes.d.ts.map +1 -1
- package/dist/testing/fakes.js.map +1 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +1 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/triage/heuristic.d.ts +5 -0
- package/dist/triage/heuristic.d.ts.map +1 -1
- package/dist/triage/heuristic.js +42 -3
- package/dist/triage/heuristic.js.map +1 -1
- package/dist/triage/index.d.ts +1 -1
- package/dist/triage/index.d.ts.map +1 -1
- package/dist/triage/index.js +1 -1
- package/dist/triage/index.js.map +1 -1
- package/dist/triage/schema.d.ts +73 -55
- package/dist/triage/schema.d.ts.map +1 -1
- package/dist/triage/schema.js +2 -1
- package/dist/triage/schema.js.map +1 -1
- package/dist/types.d.ts +19 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/writeback/github.d.ts +22 -7
- package/dist/writeback/github.d.ts.map +1 -1
- package/dist/writeback/github.js +86 -16
- package/dist/writeback/github.js.map +1 -1
- package/dist/writeback/index.d.ts +1 -1
- package/dist/writeback/index.d.ts.map +1 -1
- package/dist/writeback/index.js +1 -1
- package/dist/writeback/index.js.map +1 -1
- package/package.json +13 -8
|
@@ -5,6 +5,7 @@ import { FactoryConfigSchema } from '../config/schema.js';
|
|
|
5
5
|
import { linearByStatePath, linearByIdPath, linearByUuidPath } from '../constants/linear.js';
|
|
6
6
|
import { stateResolutionFromIds } from '../linear/state-resolver.js';
|
|
7
7
|
import { GithubMergeGate, closeProbePr } from '../github/index.js';
|
|
8
|
+
import { factoryGithubIssueCommentDraftName, isFactoryGithubIssueCommentDraftName, isFactoryGithubOperationDraftName, } from '../github/writeback-paths.js';
|
|
8
9
|
import { VerificationPipeline } from '../environments/verification-pipeline.js';
|
|
9
10
|
import { factoryWorktreeIssueSlug, factoryWorktreePath } from '../git/agent-worktree.js';
|
|
10
11
|
import { InMemoryStateStore } from '../state/in-memory-state-store.js';
|
|
@@ -12,13 +13,14 @@ import { containsExplicitIssueReference, containsIssueKey, factoryBranchBelongsT
|
|
|
12
13
|
import { normalizeLogger, normalizeLogValue, setSafeErrorStack, stringifyLogValue } from '../logging.js';
|
|
13
14
|
import { isInFactoryScope } from '../safety/factory-scope.js';
|
|
14
15
|
import { dispatchRelayflowForChangeEvent } from '../dispatch/relayflow-registry.js';
|
|
16
|
+
import { dispatchAgentIdentityKey } from '../dispatch/work-unit-identity.js';
|
|
15
17
|
import { deriveDescriptorsFromMount, prescriptiveInstructions, } from '@agent-relay/integration-prompts';
|
|
16
18
|
import { parseGithubHumanInputRequest, renderAgentTask, } from '../dispatch/templates.js';
|
|
17
19
|
import { resolveTestGuidance } from '../dispatch/test-guidance.js';
|
|
18
|
-
import { HeuristicTriage, TieredTriage, babysitterSpec, isShapeLabel, scopeFromLabels } from '../triage/index.js';
|
|
20
|
+
import { HeuristicTriage, TieredTriage, babysitterSpec, isShapeLabel, scopeFromLabels, swarmChannel, swarmMemberSlugs, swarmTaskFor } from '../triage/index.js';
|
|
19
21
|
import { agentNameForRole, sanitizeAgentSlug } from '../triage/agent-names.js';
|
|
20
22
|
import { isResourceSubscriptionsUnavailable } from '../subscriptions/index.js';
|
|
21
|
-
import { GhCliGithubWriteback, MountGithubRead, MountLinearWriteback, MountSlackWriteback, slackChannelAliases, slackChannelSegment } from '../writeback/index.js';
|
|
23
|
+
import { AppGithubWriteback, FACTORY_GITHUB_STATUS_LABELS, GhCliGithubWriteback, MountGithubRead, MountLinearWriteback, MountSlackWriteback, slackChannelAliases, slackChannelSegment } from '../writeback/index.js';
|
|
22
24
|
import { parseSlackThreadReply, slackThreadReplyGlob } from '../subscriptions/slack-filter.js';
|
|
23
25
|
import { asRecord, parseJsonContent, stableHash, wrappedPayload } from '../writeback/shared.js';
|
|
24
26
|
import { issueKey, } from './batch-tracker.js';
|
|
@@ -29,6 +31,7 @@ import { readFactoryInFlightRegistry, terminatePids } from './reaper.js';
|
|
|
29
31
|
import { createFactoryCloudEventV1, factoryCloudReleaseReasonV1, } from '../observability/events.js';
|
|
30
32
|
import { boundedRunCostTotal, CostLedger } from '../cost/ledger.js';
|
|
31
33
|
import { createTicketDispatchDelivery } from '../delivery/ticket-dispatch.js';
|
|
34
|
+
import { FleetControlPlaneCircuit, FleetControlPlaneCircuitOpenError, guardFleetControlPlane, } from '../fleet/control-plane-circuit.js';
|
|
32
35
|
class ClarificationWakeLeaseLostError extends Error {
|
|
33
36
|
}
|
|
34
37
|
class ClarificationQuestionDeliveryLeaseLostError extends Error {
|
|
@@ -224,6 +227,7 @@ export class FactoryLoop {
|
|
|
224
227
|
#mount;
|
|
225
228
|
#states;
|
|
226
229
|
#fleet;
|
|
230
|
+
#fleetControlPlane;
|
|
227
231
|
#ticketDispatchDelivery;
|
|
228
232
|
#triage;
|
|
229
233
|
#linear;
|
|
@@ -452,7 +456,6 @@ export class FactoryLoop {
|
|
|
452
456
|
// synced records (state.name but no state.id) without the states catalog.
|
|
453
457
|
this.#states = ports.stateResolution ?? stateResolutionFromIds(config.stateIds, config.linear.states);
|
|
454
458
|
installFactoryDraftPredicate(this.#mount, config);
|
|
455
|
-
this.#fleet = ports.fleet;
|
|
456
459
|
this.#ticketDispatchDelivery = ports.ticketDispatchDelivery ?? createTicketDispatchDelivery({
|
|
457
460
|
mountRoot: config.localMountRoot,
|
|
458
461
|
});
|
|
@@ -461,7 +464,7 @@ export class FactoryLoop {
|
|
|
461
464
|
safety: config.safety,
|
|
462
465
|
});
|
|
463
466
|
this.#githubWritebackProvided = Boolean(ports.githubWriteback);
|
|
464
|
-
this.#githubWriteback = ports.githubWriteback ??
|
|
467
|
+
this.#githubWriteback = ports.githubWriteback ?? defaultGithubWriteback(config, ports.mount);
|
|
465
468
|
this.#slack = config.slack ? MountSlackWriteback(ports.mount, config.slack) : ports.slack;
|
|
466
469
|
this.#github = ports.github ?? MountGithubRead(ports.mount);
|
|
467
470
|
this.#mergeGate = ports.mergeGate ?? new GithubMergeGate();
|
|
@@ -483,6 +486,13 @@ export class FactoryLoop {
|
|
|
483
486
|
this.#probePrResolver = ports.probePrResolver ?? ((issue) => this.#resolveIssuePr(issue));
|
|
484
487
|
this.#logger = normalizeLogger(ports.logger ?? console);
|
|
485
488
|
this.#clock = ports.clock ?? realClock;
|
|
489
|
+
this.#fleetControlPlane = new FleetControlPlaneCircuit({
|
|
490
|
+
timeoutMs: config.fleetHealth.rosterTimeoutMs,
|
|
491
|
+
failureThreshold: config.fleetHealth.failureThreshold,
|
|
492
|
+
resetTimeoutMs: config.fleetHealth.resetTimeoutMs,
|
|
493
|
+
now: () => this.#clock.now(),
|
|
494
|
+
});
|
|
495
|
+
this.#fleet = guardFleetControlPlane(ports.fleet, this.#fleetControlPlane);
|
|
486
496
|
this.#processIdentityReader = ports.processIdentityReader ?? readProcessIdentity;
|
|
487
497
|
this.#processFinder = ports.processFinder ?? ((agentName, opts) => findAgentProcessByName(agentName, {
|
|
488
498
|
readProcessIdentity: this.#processIdentityReader,
|
|
@@ -590,11 +600,32 @@ export class FactoryLoop {
|
|
|
590
600
|
this.#batchView = batch;
|
|
591
601
|
return batch;
|
|
592
602
|
}
|
|
603
|
+
/**
|
|
604
|
+
* Resolves once this issue's durable dispatch row reaches a terminal phase,
|
|
605
|
+
* and reports which one. A caller that turns the run into an exit code needs
|
|
606
|
+
* the phase: a dispatch that hit capacity returns an empty hold result and
|
|
607
|
+
* schedules a durable retry, so the pre-wait result says nothing about how
|
|
608
|
+
* the run actually ended.
|
|
609
|
+
*
|
|
610
|
+
* `undefined` means no terminal phase was observed — there was no lifecycle
|
|
611
|
+
* row to wait on, or the wait ended because Factory is stopping.
|
|
612
|
+
*/
|
|
593
613
|
async waitForDispatchTerminal(issue) {
|
|
594
614
|
const key = issueKey(issue);
|
|
595
615
|
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
596
|
-
|
|
597
|
-
|
|
616
|
+
// No durable row means this dispatch never claimed a lifecycle: a
|
|
617
|
+
// dependency park, a triage escalation, and a label refusal all return
|
|
618
|
+
// before the claim. Nothing can ever become terminal, so polling would
|
|
619
|
+
// never stop and the caller would never produce an exit code at all.
|
|
620
|
+
if (!lifecycle)
|
|
621
|
+
return undefined;
|
|
622
|
+
if (isTerminalDispatchLifecycle(lifecycle))
|
|
623
|
+
return lifecycle.phase;
|
|
624
|
+
// Capture the phase at the moment this waiter observes it. The waiters are
|
|
625
|
+
// shared across callers of one row and carry no payload, so a re-read after
|
|
626
|
+
// the fact can race lifecycle cleanup or a reopened dispatch for the same
|
|
627
|
+
// issue and report a different run — or none.
|
|
628
|
+
let observedPhase;
|
|
598
629
|
await new Promise((resolve) => {
|
|
599
630
|
let settled = false;
|
|
600
631
|
let timer;
|
|
@@ -603,10 +634,11 @@ export class FactoryLoop {
|
|
|
603
634
|
waiters = new Set();
|
|
604
635
|
this.#dispatchTerminalWaiters.set(key, waiters);
|
|
605
636
|
}
|
|
606
|
-
const finish = () => {
|
|
637
|
+
const finish = (phase) => {
|
|
607
638
|
if (settled)
|
|
608
639
|
return;
|
|
609
640
|
settled = true;
|
|
641
|
+
observedPhase = phase;
|
|
610
642
|
if (timer)
|
|
611
643
|
clearTimeout(timer);
|
|
612
644
|
const current = this.#dispatchTerminalWaiters.get(key);
|
|
@@ -629,7 +661,7 @@ export class FactoryLoop {
|
|
|
629
661
|
try {
|
|
630
662
|
const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
631
663
|
if (latest && isTerminalDispatchLifecycle(latest)) {
|
|
632
|
-
this.#resolveDispatchTerminalWaiters(issue);
|
|
664
|
+
this.#resolveDispatchTerminalWaiters(issue, latest.phase);
|
|
633
665
|
return;
|
|
634
666
|
}
|
|
635
667
|
if (latest?.phase === 'waiting-for-human' && this.#startMode === 'dispatch-owner') {
|
|
@@ -659,6 +691,10 @@ export class FactoryLoop {
|
|
|
659
691
|
};
|
|
660
692
|
void poll();
|
|
661
693
|
});
|
|
694
|
+
// `observedPhase` is whatever resolved THIS waiter. It stays undefined only
|
|
695
|
+
// when the wait ended without a terminal resolution at all — Factory is
|
|
696
|
+
// stopping — which is exactly what `undefined` reports.
|
|
697
|
+
return observedPhase;
|
|
662
698
|
}
|
|
663
699
|
async start(opts = {}) {
|
|
664
700
|
if (this.#started) {
|
|
@@ -1831,8 +1867,30 @@ export class FactoryLoop {
|
|
|
1831
1867
|
}
|
|
1832
1868
|
}
|
|
1833
1869
|
}
|
|
1870
|
+
async #assertFleetControlPlaneAvailable() {
|
|
1871
|
+
try {
|
|
1872
|
+
await this.#fleet.roster();
|
|
1873
|
+
this.#increment('fleetControlPlaneProbeSuccesses');
|
|
1874
|
+
}
|
|
1875
|
+
catch (error) {
|
|
1876
|
+
const health = this.#fleetControlPlane.status();
|
|
1877
|
+
this.#increment('fleetControlPlaneProbeFailures');
|
|
1878
|
+
if (health.state === 'open')
|
|
1879
|
+
this.#increment('fleetControlPlaneCircuitOpen');
|
|
1880
|
+
this.#logger.error?.('[factory] fleet control plane unavailable; dispatch paused', {
|
|
1881
|
+
state: health.state,
|
|
1882
|
+
consecutiveFailures: health.consecutiveFailures,
|
|
1883
|
+
retryAtMs: health.retryAtMs,
|
|
1884
|
+
error: health.lastError ?? 'unknown control-plane failure',
|
|
1885
|
+
});
|
|
1886
|
+
throw contextualError('Factory dispatch paused because the fleet control plane is unavailable', error);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1834
1889
|
async #runOnceWithDiscoveryFence(opts) {
|
|
1835
1890
|
const sweepStartedAtMs = this.#clock.now();
|
|
1891
|
+
if (!(opts.dryRun ?? this.#config.dryRun)) {
|
|
1892
|
+
await this.#assertFleetControlPlaneAvailable();
|
|
1893
|
+
}
|
|
1836
1894
|
let claim = await this.#state.claimDiscoverySweep(this.#workspaceId, this.#discoverySweepOwner, this.#clock.now(), DISCOVERY_SWEEP_LEASE_MS);
|
|
1837
1895
|
if (!claim.acquired && claim.reason === 'backoff') {
|
|
1838
1896
|
const delayMs = Math.max(0, claim.state.backoffUntilMs - this.#clock.now());
|
|
@@ -2699,7 +2757,7 @@ export class FactoryLoop {
|
|
|
2699
2757
|
await this.#state.clearBabysitterSession(this.#workspaceId, issueKey(lifecycle.issue));
|
|
2700
2758
|
this.#dispatchLifecycleEpochs.delete(key);
|
|
2701
2759
|
this.#abandonedDispatchReasons.delete(key);
|
|
2702
|
-
this.#resolveDispatchTerminalWaiters(lifecycle.issue);
|
|
2760
|
+
this.#resolveDispatchTerminalWaiters(lifecycle.issue, 'abandoned');
|
|
2703
2761
|
await this.#writeInFlightRegistry().catch((error) => {
|
|
2704
2762
|
this.#logger.warn?.('[factory] failed to rewrite registry after orphaned claim release', {
|
|
2705
2763
|
issue: issue.key,
|
|
@@ -3054,6 +3112,16 @@ export class FactoryLoop {
|
|
|
3054
3112
|
reports.push(failedIterationReport(error, opts.dryRun ?? this.#config.dryRun));
|
|
3055
3113
|
await this.#reapDispatchFailureHandoffsNow(heartbeatPath, registryPath);
|
|
3056
3114
|
await this.#writeLoopHeartbeat(heartbeatPath, registryPath, 'running', iteration + 1, maxIterations);
|
|
3115
|
+
const fleetControlPlane = this.#fleetControlPlane.status();
|
|
3116
|
+
if (fleetControlPlane.state !== 'closed') {
|
|
3117
|
+
this.#increment('loopCircuitBreaks');
|
|
3118
|
+
this.#logger.error?.('[factory] stopping loop because dispatch is paused by the fleet control-plane circuit', {
|
|
3119
|
+
state: fleetControlPlane.state,
|
|
3120
|
+
consecutiveFailures: fleetControlPlane.consecutiveFailures,
|
|
3121
|
+
retryAtMs: fleetControlPlane.retryAtMs,
|
|
3122
|
+
});
|
|
3123
|
+
throw new FleetControlPlaneCircuitOpenError(fleetControlPlane.retryAtMs ?? this.#clock.now(), fleetControlPlane.state);
|
|
3124
|
+
}
|
|
3057
3125
|
if (consecutiveFailures >= maxConsecutiveFailures) {
|
|
3058
3126
|
this.#increment('loopCircuitBreaks');
|
|
3059
3127
|
this.#logger.error?.('[factory] stopping loop after consecutive iteration failures', {
|
|
@@ -3209,6 +3277,13 @@ export class FactoryLoop {
|
|
|
3209
3277
|
};
|
|
3210
3278
|
}
|
|
3211
3279
|
this.#clearDependencyPark(batch, dispatchDecision.issue);
|
|
3280
|
+
// Event-driven and direct dispatches do not necessarily pass through issue
|
|
3281
|
+
// discovery. Admit them before creating previews, claiming a lifecycle, or
|
|
3282
|
+
// consuming a dispatch attempt. The mutation proxy probes again at the
|
|
3283
|
+
// actual spawn/resume boundary so a later control-plane fault still fails
|
|
3284
|
+
// closed.
|
|
3285
|
+
if (!dryRun)
|
|
3286
|
+
await this.#assertFleetControlPlaneAvailable();
|
|
3212
3287
|
const durableDispatch = !dryRun && this.#usesDurableDispatchLifecycle();
|
|
3213
3288
|
// Local dispatches need the same deterministic branch identity as remote
|
|
3214
3289
|
// ones. Without it, every worker starts in the configured shared checkout
|
|
@@ -3512,6 +3587,7 @@ export class FactoryLoop {
|
|
|
3512
3587
|
capacityBlocked: parked.capacityBlocked,
|
|
3513
3588
|
})) ?? [],
|
|
3514
3589
|
counters: { ...this.#counters },
|
|
3590
|
+
fleetControlPlane: this.#fleetControlPlane.status(),
|
|
3515
3591
|
slackDegraded: this.#slackDegraded,
|
|
3516
3592
|
slackDegradedReason: this.#slackDegradedReason,
|
|
3517
3593
|
eventListener: this.#eventListenerStatus(),
|
|
@@ -4415,12 +4491,25 @@ export class FactoryLoop {
|
|
|
4415
4491
|
}
|
|
4416
4492
|
for (const agent of lifecycle.agents) {
|
|
4417
4493
|
const previousAgent = previous?.agents.find((candidate) => candidate.name === agent.name);
|
|
4418
|
-
|
|
4419
|
-
|
|
4494
|
+
// The in-flight record is authoritative for release state: the stamp
|
|
4495
|
+
// now lives on the tracked agent and round-trips through the lifecycle,
|
|
4496
|
+
// so a still-released agent brings its own stamp here. Inheriting the
|
|
4497
|
+
// previous row's stamp instead would refile a respawned worker as
|
|
4498
|
+
// released — a respawn reuses both the agent name and the deterministic
|
|
4499
|
+
// invocation id, so neither can tell the generations apart — and every
|
|
4500
|
+
// consumer that filters on `releasedAtMs` would then treat a live
|
|
4501
|
+
// worker as gone.
|
|
4420
4502
|
if (previousAgent?.costUsage)
|
|
4421
4503
|
agent.costUsage = structuredClone(previousAgent.costUsage);
|
|
4422
|
-
if (releasedAgentNames.has(agent.name))
|
|
4423
|
-
agent.releasedAtMs
|
|
4504
|
+
if (releasedAgentNames.has(agent.name)) {
|
|
4505
|
+
const releasedAtMs = agent.releasedAtMs ?? this.#clock.now();
|
|
4506
|
+
agent.releasedAtMs = releasedAtMs;
|
|
4507
|
+
// Keep the record the single source of the stamp, so the next save
|
|
4508
|
+
// carries it without having to read it back off the durable row.
|
|
4509
|
+
const tracked = record.agents.get(agent.name);
|
|
4510
|
+
if (tracked)
|
|
4511
|
+
tracked.releasedAtMs ??= releasedAtMs;
|
|
4512
|
+
}
|
|
4424
4513
|
}
|
|
4425
4514
|
const saved = await this.#state.saveDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now(), lifecycle);
|
|
4426
4515
|
if (!saved) {
|
|
@@ -4448,10 +4537,14 @@ export class FactoryLoop {
|
|
|
4448
4537
|
return true;
|
|
4449
4538
|
});
|
|
4450
4539
|
}
|
|
4451
|
-
|
|
4540
|
+
// Every caller of this knows the phase the row settled in, and each waiter
|
|
4541
|
+
// must be handed it directly. A waiter that instead re-read the shared row
|
|
4542
|
+
// after release could see it cleared, or see the next dispatch for the same
|
|
4543
|
+
// issue, and classify the wrong run.
|
|
4544
|
+
#resolveDispatchTerminalWaiters(issue, phase) {
|
|
4452
4545
|
const key = issueKey(issue);
|
|
4453
4546
|
for (const resolve of this.#dispatchTerminalWaiters.get(key) ?? [])
|
|
4454
|
-
resolve();
|
|
4547
|
+
resolve(phase);
|
|
4455
4548
|
this.#dispatchTerminalWaiters.delete(key);
|
|
4456
4549
|
}
|
|
4457
4550
|
#scheduleDispatchLifecycleRetry(record) {
|
|
@@ -4538,7 +4631,7 @@ export class FactoryLoop {
|
|
|
4538
4631
|
if (!lifecycle)
|
|
4539
4632
|
return;
|
|
4540
4633
|
if (isTerminalDispatchLifecycle(lifecycle)) {
|
|
4541
|
-
this.#resolveDispatchTerminalWaiters(lifecycle.issue);
|
|
4634
|
+
this.#resolveDispatchTerminalWaiters(lifecycle.issue, lifecycle.phase);
|
|
4542
4635
|
return;
|
|
4543
4636
|
}
|
|
4544
4637
|
if (lifecycle.phase === 'waiting-for-human')
|
|
@@ -4919,7 +5012,7 @@ export class FactoryLoop {
|
|
|
4919
5012
|
await this.#stopGithubIssueCommentWatcherForIssue(record.issue);
|
|
4920
5013
|
await this.#writeInFlightRegistry();
|
|
4921
5014
|
this.#increment('dispatchLifecycleStaleIssuesAbandoned');
|
|
4922
|
-
this.#resolveDispatchTerminalWaiters(record.issue);
|
|
5015
|
+
this.#resolveDispatchTerminalWaiters(record.issue, 'abandoned');
|
|
4923
5016
|
this.#logger.info?.('[factory] abandoned durable dispatch whose live issue is no longer ready', {
|
|
4924
5017
|
issue: record.issue.key,
|
|
4925
5018
|
reason,
|
|
@@ -4995,7 +5088,7 @@ export class FactoryLoop {
|
|
|
4995
5088
|
this.#increment(releaseReason === 'issue-human-review' ? 'humanReview' : 'done');
|
|
4996
5089
|
this.#emit('issue-done', { issue: record.issue });
|
|
4997
5090
|
await this.#writeInFlightRegistry();
|
|
4998
|
-
this.#resolveDispatchTerminalWaiters(record.issue);
|
|
5091
|
+
this.#resolveDispatchTerminalWaiters(record.issue, 'complete');
|
|
4999
5092
|
return true;
|
|
5000
5093
|
}
|
|
5001
5094
|
async #assertDispatchLifecycleOwner(record) {
|
|
@@ -6019,6 +6112,7 @@ export class FactoryLoop {
|
|
|
6019
6112
|
registryPath,
|
|
6020
6113
|
eventListener: this.#eventListenerStatus(),
|
|
6021
6114
|
readinessReconcile: this.#readinessReconcileStatus(),
|
|
6115
|
+
fleetControlPlane: this.#fleetControlPlane.status(),
|
|
6022
6116
|
};
|
|
6023
6117
|
await mkdir(dirname(path), { recursive: true });
|
|
6024
6118
|
await writeFile(path, `${JSON.stringify(heartbeat, null, 2)}\n`, 'utf8');
|
|
@@ -6277,6 +6371,22 @@ export class FactoryLoop {
|
|
|
6277
6371
|
}
|
|
6278
6372
|
try {
|
|
6279
6373
|
await this.#fleet.release(agentName, reason);
|
|
6374
|
+
// Invalidate the spawn memory the moment the release is confirmed, so a
|
|
6375
|
+
// retry for the same deterministic invocation spawns a real worker
|
|
6376
|
+
// instead of inheriting this one's claim. Shutdown is excluded on
|
|
6377
|
+
// purpose: nothing is being retried there, and a takeover must still be
|
|
6378
|
+
// free to adopt agents this process merely stopped supervising.
|
|
6379
|
+
if (record && batch && context !== 'stop') {
|
|
6380
|
+
const releasedInvocationId = batch.recordRelease(record, agentName, this.#clock.now());
|
|
6381
|
+
if (releasedInvocationId) {
|
|
6382
|
+
this.#logger.debug?.('[factory] released agent invocation is no longer dispatchable', {
|
|
6383
|
+
issue: record.issue.key,
|
|
6384
|
+
agentName,
|
|
6385
|
+
reason,
|
|
6386
|
+
invocationId: releasedInvocationId,
|
|
6387
|
+
});
|
|
6388
|
+
}
|
|
6389
|
+
}
|
|
6280
6390
|
if (record)
|
|
6281
6391
|
await this.#reportAgent(record, tracked, 'agent.released', { releaseReason: reason });
|
|
6282
6392
|
}
|
|
@@ -6506,7 +6616,10 @@ export class FactoryLoop {
|
|
|
6506
6616
|
const batch = await this.#batch();
|
|
6507
6617
|
const invocationId = batch.invocationIdFor(record.issue, spec);
|
|
6508
6618
|
const existing = record.agents.get(spec.name);
|
|
6509
|
-
|
|
6619
|
+
// A released placement keeps its bookkeeping entry, but it is not a worker.
|
|
6620
|
+
// Answering with its old spawn result here would report a synthetic success
|
|
6621
|
+
// for a process that no longer exists.
|
|
6622
|
+
if (existing?.result && existing.releasedAtMs === undefined) {
|
|
6510
6623
|
this.#scheduleHeldAgentDeadline(record);
|
|
6511
6624
|
return { name: existing.result?.name ?? spec.name };
|
|
6512
6625
|
}
|
|
@@ -6556,6 +6669,7 @@ export class FactoryLoop {
|
|
|
6556
6669
|
result = await this.#fleet.spawn({
|
|
6557
6670
|
name: spec.name,
|
|
6558
6671
|
capability: spec.capability,
|
|
6672
|
+
identityKey: dispatchAgentIdentityKey(record.issue, spec.role),
|
|
6559
6673
|
node: spec.node ?? 'self',
|
|
6560
6674
|
repo: spec.repo,
|
|
6561
6675
|
task: spec.task,
|
|
@@ -6657,6 +6771,16 @@ export class FactoryLoop {
|
|
|
6657
6771
|
return;
|
|
6658
6772
|
}
|
|
6659
6773
|
}
|
|
6774
|
+
// Swarm workers share the lead's checkout and lifecycle branch. If a worker
|
|
6775
|
+
// exit reached the publication/completion paths below, whichever worker
|
|
6776
|
+
// finished first would publish whatever partial state was on the shared
|
|
6777
|
+
// branch and mark every swarm member "done" via the shared-branch PR probe,
|
|
6778
|
+
// releasing the still-working lead. The lead alone is authoritative for
|
|
6779
|
+
// publication and completion in a swarm.
|
|
6780
|
+
if (exiting?.spec.swarmRole === 'worker') {
|
|
6781
|
+
this.#increment('swarmWorkerExitsSuppressed');
|
|
6782
|
+
return;
|
|
6783
|
+
}
|
|
6660
6784
|
if (isCompletionReason(reason)) {
|
|
6661
6785
|
if (exiting?.spec.role === 'implementer' && await this.#issueHasCompletionPr(record, {
|
|
6662
6786
|
openOnly: this.#config.babysitter.enabled,
|
|
@@ -6901,6 +7025,7 @@ export class FactoryLoop {
|
|
|
6901
7025
|
const result = await this.#fleet.spawn({
|
|
6902
7026
|
name: tracked.spec.name,
|
|
6903
7027
|
capability: tracked.spec.capability,
|
|
7028
|
+
identityKey: dispatchAgentIdentityKey(record.issue, tracked.spec.role),
|
|
6904
7029
|
node: tracked.result?.node ?? tracked.spec.node ?? 'self',
|
|
6905
7030
|
repo: tracked.spec.repo,
|
|
6906
7031
|
task: tracked.spec.task,
|
|
@@ -7782,6 +7907,7 @@ export class FactoryLoop {
|
|
|
7782
7907
|
const result = await this.#fleet.resume({
|
|
7783
7908
|
name,
|
|
7784
7909
|
sessionRef: tracked.sessionRef,
|
|
7910
|
+
identityKey: dispatchAgentIdentityKey(record.issue, tracked.spec.role),
|
|
7785
7911
|
node: tracked.result?.node ?? tracked.spec.node ?? 'self',
|
|
7786
7912
|
capability: tracked.spec.capability,
|
|
7787
7913
|
repo: tracked.spec.repo,
|
|
@@ -7910,6 +8036,7 @@ export class FactoryLoop {
|
|
|
7910
8036
|
const result = await this.#fleet.spawn({
|
|
7911
8037
|
name: replacementSpec.name,
|
|
7912
8038
|
capability: replacementSpec.capability,
|
|
8039
|
+
identityKey: dispatchAgentIdentityKey(record.issue, replacementSpec.role),
|
|
7913
8040
|
node: tracked.result?.node ?? replacementSpec.node ?? 'self',
|
|
7914
8041
|
repo: replacementSpec.repo,
|
|
7915
8042
|
task: replacementSpec.task,
|
|
@@ -9377,6 +9504,15 @@ export class FactoryLoop {
|
|
|
9377
9504
|
previewStartCommand: spec.preview?.startCommand,
|
|
9378
9505
|
} : {}),
|
|
9379
9506
|
...(this.#fleet.lifecycleActionName ? { lifecycleActionName: this.#fleet.lifecycleActionName } : {}),
|
|
9507
|
+
...(spec.swarmRole && spec.channel ? {
|
|
9508
|
+
swarm: {
|
|
9509
|
+
role: spec.swarmRole,
|
|
9510
|
+
channel: spec.channel,
|
|
9511
|
+
otherMemberNames: decision.implementers
|
|
9512
|
+
.filter((implementer) => implementer.channel === spec.channel && implementer.name !== spec.name)
|
|
9513
|
+
.map((implementer) => implementer.name),
|
|
9514
|
+
},
|
|
9515
|
+
} : {}),
|
|
9380
9516
|
}),
|
|
9381
9517
|
};
|
|
9382
9518
|
};
|
|
@@ -10897,7 +11033,7 @@ export class FactoryLoop {
|
|
|
10897
11033
|
});
|
|
10898
11034
|
return;
|
|
10899
11035
|
}
|
|
10900
|
-
const issue = await this.#findMergeAdvanceIssueForPr(snapshot);
|
|
11036
|
+
const issue = await this.#findMergeAdvanceIssueForPr(snapshot, repo);
|
|
10901
11037
|
if (!issue) {
|
|
10902
11038
|
this.#increment('mergedPrAdvanceNoIssue');
|
|
10903
11039
|
return;
|
|
@@ -10952,7 +11088,7 @@ export class FactoryLoop {
|
|
|
10952
11088
|
this.#error(error, issueRef(issue));
|
|
10953
11089
|
}
|
|
10954
11090
|
}
|
|
10955
|
-
async #findMergeAdvanceIssueForPr(snapshot) {
|
|
11091
|
+
async #findMergeAdvanceIssueForPr(snapshot, eventRepo) {
|
|
10956
11092
|
// An issue is "upstream" of a merge if it sits in the agent-implementing or
|
|
10957
11093
|
// human-review role for its team. UUIDs are globally unique, so the reverse
|
|
10958
11094
|
// role lookup covers every team without per-team scoping here.
|
|
@@ -10961,16 +11097,34 @@ export class FactoryLoop {
|
|
|
10961
11097
|
return role === 'agentImplementing' || role === 'humanReview';
|
|
10962
11098
|
};
|
|
10963
11099
|
let best;
|
|
11100
|
+
let ambiguous = false;
|
|
10964
11101
|
const scanStartedAtMs = this.#clock.now();
|
|
10965
11102
|
// This no-record path runs after agents are released, so there is no
|
|
10966
11103
|
// tracked PR identity left. Keep the scan simple and prefer branch identity
|
|
10967
11104
|
// over title/body references to avoid "related to AR-N" body false positives.
|
|
10968
11105
|
const githubSource = await this.#issueSource() === 'github';
|
|
11106
|
+
// A GitHub-native issue key is a bare number; the same number exists
|
|
11107
|
+
// independently in every configured repository. Only a same-repo candidate
|
|
11108
|
+
// may complete the merge, so a merged PR in one repo can never close an
|
|
11109
|
+
// unrelated issue that happens to share its number in another. #276.
|
|
11110
|
+
const eventRepoKey = validGithubRepo(eventRepo) ? eventRepo.toLowerCase() : undefined;
|
|
11111
|
+
if (githubSource && !eventRepoKey) {
|
|
11112
|
+
this.#logger.warn?.('[factory] merge advance skipped: event repo is not identifiable', {
|
|
11113
|
+
prNumber: snapshot.number,
|
|
11114
|
+
});
|
|
11115
|
+
return undefined;
|
|
11116
|
+
}
|
|
10969
11117
|
const paths = githubSource ? await this.#githubIssuePaths() : await this.#listRelayfileTree(ISSUE_ROOT, 'merge advance issue scan');
|
|
10970
11118
|
for (const path of paths) {
|
|
10971
11119
|
if (githubSource ? !isGithubIssueFilePath(path) : !isIssueFilePath(path)) {
|
|
10972
11120
|
continue;
|
|
10973
11121
|
}
|
|
11122
|
+
if (githubSource) {
|
|
11123
|
+
const issueParts = githubIssuePathParts(path);
|
|
11124
|
+
if (!issueParts || `${issueParts.owner}/${issueParts.repo}`.toLowerCase() !== eventRepoKey) {
|
|
11125
|
+
continue;
|
|
11126
|
+
}
|
|
11127
|
+
}
|
|
10974
11128
|
const issue = await this.#readIssue(path);
|
|
10975
11129
|
if (!issue || (githubSource
|
|
10976
11130
|
? issue.state?.name?.trim().toLowerCase() === 'closed'
|
|
@@ -10983,10 +11137,27 @@ export class FactoryLoop {
|
|
|
10983
11137
|
const score = prSnapshotIssueMatchScore(snapshot, issue.key);
|
|
10984
11138
|
if (score > 0 && (!best || score > best.score)) {
|
|
10985
11139
|
best = { issue, score };
|
|
11140
|
+
ambiguous = false;
|
|
10986
11141
|
}
|
|
11142
|
+
else if (score > 0 && best && score === best.score) {
|
|
11143
|
+
ambiguous = true;
|
|
11144
|
+
}
|
|
11145
|
+
}
|
|
11146
|
+
// Fail-closed on a tie. A silent first-wins on unrelated same-score
|
|
11147
|
+
// candidates is the exact class of quiet corruption we are removing.
|
|
11148
|
+
if (ambiguous) {
|
|
11149
|
+
this.#increment('mergedPrAdvanceAmbiguous');
|
|
11150
|
+
this.#logger.warn?.('[factory] merge advance skipped: multiple candidates tied on match score', {
|
|
11151
|
+
prNumber: snapshot.number,
|
|
11152
|
+
eventRepo,
|
|
11153
|
+
matchedIssue: best?.issue.key,
|
|
11154
|
+
matchScore: best?.score,
|
|
11155
|
+
});
|
|
11156
|
+
return undefined;
|
|
10987
11157
|
}
|
|
10988
11158
|
this.#logger.debug?.(`[factory] scanned ${githubSource ? 'GitHub' : 'Linear'} issues for merged PR advance`, {
|
|
10989
11159
|
prNumber: snapshot.number,
|
|
11160
|
+
eventRepo,
|
|
10990
11161
|
durationMs: this.#clock.now() - scanStartedAtMs,
|
|
10991
11162
|
matchedIssue: best?.issue.key,
|
|
10992
11163
|
matchScore: best?.score,
|
|
@@ -12157,6 +12328,7 @@ export class FactoryLoop {
|
|
|
12157
12328
|
const rebound = await this.#state.rebindConversationSession(this.#workspaceId, conversationId, {
|
|
12158
12329
|
name: agentName,
|
|
12159
12330
|
sessionRef,
|
|
12331
|
+
role: owned.tracked.spec.role,
|
|
12160
12332
|
node: owned.tracked.result?.node ?? owned.tracked.spec.node,
|
|
12161
12333
|
capability: owned.tracked.spec.capability,
|
|
12162
12334
|
repo: owned.tracked.spec.repo,
|
|
@@ -12189,6 +12361,7 @@ export class FactoryLoop {
|
|
|
12189
12361
|
agent: {
|
|
12190
12362
|
name: agentName,
|
|
12191
12363
|
sessionRef,
|
|
12364
|
+
role: owned.tracked.spec.role,
|
|
12192
12365
|
node: owned.tracked.result?.node ?? owned.tracked.spec.node,
|
|
12193
12366
|
capability: owned.tracked.spec.capability,
|
|
12194
12367
|
repo: owned.tracked.spec.repo,
|
|
@@ -12252,9 +12425,20 @@ export class FactoryLoop {
|
|
|
12252
12425
|
}, Math.max(1_000, Math.floor(SLACK_CONVERSATION_TURN_LEASE_MS / 3)));
|
|
12253
12426
|
heartbeat.unref?.();
|
|
12254
12427
|
try {
|
|
12428
|
+
// Sessions bound after this field was introduced carry their role
|
|
12429
|
+
// durably, so resume never depends on a live lookup by the agent's
|
|
12430
|
+
// current name — which a since-completed rename (e.g. babysitter
|
|
12431
|
+
// retarget) can otherwise leave unresolvable forever. Older
|
|
12432
|
+
// already-persisted sessions fall back to the live lookup.
|
|
12433
|
+
const conversationRole = claimed.agent.role
|
|
12434
|
+
?? (await this.#batch()).getIssueByAgent(claimed.agent.name)?.agents.get(claimed.agent.name)?.spec.role;
|
|
12435
|
+
if (!conversationRole) {
|
|
12436
|
+
throw new Error(`Cannot resume ${claimed.agent.name}: its dispatch role is unavailable for identity proof`);
|
|
12437
|
+
}
|
|
12255
12438
|
const result = await this.#fleet.resume({
|
|
12256
12439
|
name: claimed.agent.name,
|
|
12257
12440
|
sessionRef: claimed.agent.sessionRef,
|
|
12441
|
+
identityKey: dispatchAgentIdentityKey(claimed.issue, conversationRole),
|
|
12258
12442
|
node: claimed.agent.node ?? 'self',
|
|
12259
12443
|
capability: claimed.agent.capability,
|
|
12260
12444
|
repo: claimed.agent.repo,
|
|
@@ -13414,6 +13598,7 @@ export class FactoryLoop {
|
|
|
13414
13598
|
const resumed = await this.#fleet.resume({
|
|
13415
13599
|
name,
|
|
13416
13600
|
sessionRef: tracked.sessionRef,
|
|
13601
|
+
identityKey: dispatchAgentIdentityKey(waiting.issue, tracked.spec.role),
|
|
13417
13602
|
node: tracked.result?.node ?? tracked.spec.node ?? 'self',
|
|
13418
13603
|
capability: tracked.spec.capability,
|
|
13419
13604
|
repo: tracked.spec.repo,
|
|
@@ -13444,6 +13629,7 @@ export class FactoryLoop {
|
|
|
13444
13629
|
return await this.#fleet.spawn({
|
|
13445
13630
|
name,
|
|
13446
13631
|
capability: tracked.spec.capability,
|
|
13632
|
+
identityKey: dispatchAgentIdentityKey(waiting.issue, tracked.spec.role),
|
|
13447
13633
|
node: tracked.result?.node ?? tracked.spec.node ?? 'self',
|
|
13448
13634
|
task,
|
|
13449
13635
|
workflow: tracked.spec.workflow,
|
|
@@ -13757,6 +13943,15 @@ export class FactoryLoop {
|
|
|
13757
13943
|
return hasTitlePrefix(issue.title, FACTORY_E2E_MARKER);
|
|
13758
13944
|
}
|
|
13759
13945
|
}
|
|
13946
|
+
const defaultGithubWriteback = (config, mount) => {
|
|
13947
|
+
if (config.github.identity !== 'app') {
|
|
13948
|
+
return new GhCliGithubWriteback();
|
|
13949
|
+
}
|
|
13950
|
+
if (!mount.githubWrite) {
|
|
13951
|
+
throw new Error('GitHub identity "app" requires a connected workspace GitHub App lifecycle write path; refusing to fall back to the local gh user');
|
|
13952
|
+
}
|
|
13953
|
+
return new AppGithubWriteback(mount.githubWrite);
|
|
13954
|
+
};
|
|
13760
13955
|
export function parseLinearIssue(path, content) {
|
|
13761
13956
|
const parsed = parseJsonContent(content);
|
|
13762
13957
|
const payload = wrappedPayload(parsed);
|
|
@@ -14352,13 +14547,19 @@ function labelDerivedDispatchDecision(liveIssue, decision, config) {
|
|
|
14352
14547
|
maxImplementers,
|
|
14353
14548
|
};
|
|
14354
14549
|
}
|
|
14355
|
-
|
|
14356
|
-
|
|
14550
|
+
// Swarm always shares one checkout (lead + workers collaborate live over a
|
|
14551
|
+
// shared relay channel), so — like single/workflow — only the first matched
|
|
14552
|
+
// label route is used; it is never fanned out across repo labels like team.
|
|
14553
|
+
const selectedRoutes = scope === 'single' || scope === 'swarm'
|
|
14554
|
+
? routesByLabel.routes.slice(0, 1)
|
|
14555
|
+
: routesByLabel.routes;
|
|
14357
14556
|
const selectedImplementers = scope === 'team'
|
|
14358
|
-
?
|
|
14557
|
+
? routesByLabel.routes.map(({ slug, route }) => routeImplementerSpec(liveIssue, config, slug, route))
|
|
14359
14558
|
: scope === 'single'
|
|
14360
|
-
?
|
|
14361
|
-
:
|
|
14559
|
+
? routesByLabel.routes.slice(0, 1).map(({ slug, route }) => routeImplementerSpec(liveIssue, config, slug, route))
|
|
14560
|
+
: scope === 'swarm'
|
|
14561
|
+
? routeSwarmImplementerSpecs(liveIssue, config, selectedRoutes[0]?.route, maxImplementers)
|
|
14562
|
+
: [];
|
|
14362
14563
|
const routes = selectedRoutes.map(({ route }) => route);
|
|
14363
14564
|
const workflow = scope === 'workflow'
|
|
14364
14565
|
? routeWorkflowSpec(liveIssue, config, selectedRoutes, decision.workflow)
|
|
@@ -14487,6 +14688,24 @@ function routeImplementerSpec(issue, config, slug, route) {
|
|
|
14487
14688
|
node: 'self',
|
|
14488
14689
|
};
|
|
14489
14690
|
}
|
|
14691
|
+
function routeSwarmImplementerSpecs(issue, config, route, maxImplementers) {
|
|
14692
|
+
if (!route) {
|
|
14693
|
+
return [];
|
|
14694
|
+
}
|
|
14695
|
+
const channel = swarmChannel(issue);
|
|
14696
|
+
return swarmMemberSlugs(maxImplementers).map((slug) => ({
|
|
14697
|
+
name: agentNameForRole(issue, 'impl', { repo: route.repo, discriminator: slug }),
|
|
14698
|
+
role: 'implementer',
|
|
14699
|
+
capability: config.agentCapabilities.implementer,
|
|
14700
|
+
model: config.models.implementer,
|
|
14701
|
+
task: swarmTaskFor(issue, route, slug, channel),
|
|
14702
|
+
repo: route.repo,
|
|
14703
|
+
clonePath: route.clonePath,
|
|
14704
|
+
channel,
|
|
14705
|
+
swarmRole: slug === 'lead' ? 'lead' : 'worker',
|
|
14706
|
+
node: 'self',
|
|
14707
|
+
}));
|
|
14708
|
+
}
|
|
14490
14709
|
function decisionWithLifecycleBranches(decision, runId, opts = {}) {
|
|
14491
14710
|
const implementerBranch = (spec) => {
|
|
14492
14711
|
const runSuffix = `-${runId.slice(0, 8)}`;
|
|
@@ -15633,12 +15852,123 @@ const isAllowedFactoryDraft = async (path, content, opts, mount, config) => {
|
|
|
15633
15852
|
if (/^\/slack\/channels\/[^/]+\/messages\/.+/u.test(path)) {
|
|
15634
15853
|
return true;
|
|
15635
15854
|
}
|
|
15636
|
-
if (
|
|
15855
|
+
if (await isAllowedFactoryGithubDraft(path, content, opts, mount, config))
|
|
15637
15856
|
return true;
|
|
15857
|
+
return false;
|
|
15858
|
+
};
|
|
15859
|
+
const isFactoryGithubAuthoredArtifactPath = (path) => /^\/github\/repos\/[^/]+\/[^/]+\/(?:pull-requests\/factory-[^/]+\.json|refs\/(?:factory\.json|refs%2Fheads%2Ffactory%2F[^/]+\.json)|pulls\/[1-9]\d*\/close\.json)$/iu.test(path);
|
|
15860
|
+
export const isAllowedFactoryGithubArtifactDraft = (path, opts) => opts?.guarded === true && isFactoryGithubAuthoredArtifactPath(path);
|
|
15861
|
+
const factoryGithubIssueWriteTarget = (path) => {
|
|
15862
|
+
const match = /^\/github\/repos\/([^/]+)\/([^/]+)\/issues\/([1-9]\d*)(?:\.json|\/(comments|labels)\/([^/]+))$/iu.exec(path);
|
|
15863
|
+
if (!match?.[1] || !match[2] || !match[3])
|
|
15864
|
+
return undefined;
|
|
15865
|
+
const child = match[4];
|
|
15866
|
+
const filename = match[5];
|
|
15867
|
+
if (child === 'comments' && (!filename || !isFactoryGithubIssueCommentDraftName(filename)))
|
|
15868
|
+
return undefined;
|
|
15869
|
+
if (child === 'labels' && (!filename || !isFactoryGithubOperationDraftName(filename)))
|
|
15870
|
+
return undefined;
|
|
15871
|
+
try {
|
|
15872
|
+
return {
|
|
15873
|
+
owner: decodeURIComponent(match[1]),
|
|
15874
|
+
repo: decodeURIComponent(match[2]),
|
|
15875
|
+
number: Number(match[3]),
|
|
15876
|
+
kind: child === 'comments' ? 'comment' : child === 'labels' ? 'label-operation' : 'issue-update',
|
|
15877
|
+
};
|
|
15878
|
+
}
|
|
15879
|
+
catch {
|
|
15880
|
+
return undefined;
|
|
15881
|
+
}
|
|
15882
|
+
};
|
|
15883
|
+
const factoryGithubRepositoryLabelWriteTarget = (path) => {
|
|
15884
|
+
const match = /^\/github\/repos\/([^/]+)\/([^/]+)\/labels\/([^/]+)$/iu.exec(path);
|
|
15885
|
+
if (!match?.[1] || !match[2] || !match[3] || !isFactoryGithubOperationDraftName(match[3]))
|
|
15886
|
+
return undefined;
|
|
15887
|
+
try {
|
|
15888
|
+
return { owner: decodeURIComponent(match[1]), repo: decodeURIComponent(match[2]) };
|
|
15889
|
+
}
|
|
15890
|
+
catch {
|
|
15891
|
+
return undefined;
|
|
15892
|
+
}
|
|
15893
|
+
};
|
|
15894
|
+
const githubLifecycleLabel = (name) => Object.values(FACTORY_GITHUB_STATUS_LABELS).find((label) => label.name === name);
|
|
15895
|
+
const hasExactKeys = (value, keys) => {
|
|
15896
|
+
const actual = Object.keys(value).sort();
|
|
15897
|
+
const expected = [...keys].sort();
|
|
15898
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
15899
|
+
};
|
|
15900
|
+
const isAllowedFactoryGithubIssueWriteContent = (kind, content) => {
|
|
15901
|
+
const value = asRecord(content);
|
|
15902
|
+
if (!value)
|
|
15903
|
+
return false;
|
|
15904
|
+
if (kind === 'issue-update') {
|
|
15905
|
+
return hasExactKeys(value, ['state']) && value.state === 'closed';
|
|
15906
|
+
}
|
|
15907
|
+
if (kind === 'comment') {
|
|
15908
|
+
return hasExactKeys(value, ['body']) && typeof value.body === 'string' && value.body.trim().length > 0;
|
|
15909
|
+
}
|
|
15910
|
+
if (value.operation === 'add') {
|
|
15911
|
+
return hasExactKeys(value, ['labels', 'operation']) &&
|
|
15912
|
+
Array.isArray(value.labels) && value.labels.length === 1 && Boolean(githubLifecycleLabel(value.labels[0]));
|
|
15913
|
+
}
|
|
15914
|
+
return value.operation === 'remove' && hasExactKeys(value, ['label', 'operation']) && Boolean(githubLifecycleLabel(value.label));
|
|
15915
|
+
};
|
|
15916
|
+
const isAllowedFactoryGithubRepositoryLabelContent = (content) => {
|
|
15917
|
+
const value = asRecord(content);
|
|
15918
|
+
if (!value || !hasExactKeys(value, ['color', 'description', 'name']))
|
|
15919
|
+
return false;
|
|
15920
|
+
const expected = githubLifecycleLabel(value.name);
|
|
15921
|
+
return Boolean(expected && expected.color === value.color && expected.description === value.description);
|
|
15922
|
+
};
|
|
15923
|
+
/**
|
|
15924
|
+
* Last-resort mount guard for Factory-authored GitHub drafts. PR/ref paths are
|
|
15925
|
+
* intrinsically Factory-owned. Issue mutations additionally require a current
|
|
15926
|
+
* in-scope issue projection in one of Relayfile's supported repository layouts.
|
|
15927
|
+
*/
|
|
15928
|
+
export const isAllowedFactoryGithubDraft = async (path, content, opts, mount, config) => {
|
|
15929
|
+
if (!opts?.guarded)
|
|
15930
|
+
return false;
|
|
15931
|
+
if (isAllowedFactoryGithubArtifactDraft(path, opts))
|
|
15932
|
+
return true;
|
|
15933
|
+
const repositoryLabelTarget = factoryGithubRepositoryLabelWriteTarget(path);
|
|
15934
|
+
if (repositoryLabelTarget) {
|
|
15935
|
+
const repoPath = `/github/repos/${encodeURIComponent(repositoryLabelTarget.owner)}/${encodeURIComponent(repositoryLabelTarget.repo)}/`;
|
|
15936
|
+
return isConfiguredGithubRepoPath(repoPath, config) && isAllowedFactoryGithubRepositoryLabelContent(content);
|
|
15937
|
+
}
|
|
15938
|
+
const target = factoryGithubIssueWriteTarget(path);
|
|
15939
|
+
if (!target)
|
|
15940
|
+
return false;
|
|
15941
|
+
if (!isAllowedFactoryGithubIssueWriteContent(target.kind, content))
|
|
15942
|
+
return false;
|
|
15943
|
+
if (target.kind === 'comment') {
|
|
15944
|
+
const body = asRecord(content)?.body;
|
|
15945
|
+
const draftName = path.slice(path.lastIndexOf('/') + 1);
|
|
15946
|
+
if (typeof body !== 'string' || draftName !== factoryGithubIssueCommentDraftName(body))
|
|
15947
|
+
return false;
|
|
15948
|
+
}
|
|
15949
|
+
const repoPath = `/github/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}`;
|
|
15950
|
+
if (!isConfiguredGithubRepoPath(`${repoPath}/`, config))
|
|
15951
|
+
return false;
|
|
15952
|
+
const compactRepo = `${encodeURIComponent(target.owner)}__${encodeURIComponent(target.repo)}`;
|
|
15953
|
+
const candidates = [
|
|
15954
|
+
`${repoPath}/issues/by-id/${target.number}.json`,
|
|
15955
|
+
`/github/repos/${compactRepo}/issues/by-id/${target.number}.json`,
|
|
15956
|
+
`${repoPath}/issues/${target.number}/meta.json`,
|
|
15957
|
+
`/github/repos/${compactRepo}/issues/${target.number}/meta.json`,
|
|
15958
|
+
`${repoPath}/issues/${target.number}.json`,
|
|
15959
|
+
`/github/repos/${compactRepo}/issues/${target.number}.json`,
|
|
15960
|
+
];
|
|
15961
|
+
for (const candidate of candidates) {
|
|
15962
|
+
try {
|
|
15963
|
+
const issue = parseGithubFactoryIssue(candidate, (await mount.readFile(candidate)).content);
|
|
15964
|
+
return issue.state?.name === 'open' && isInFactoryScope(issue, config.safety);
|
|
15965
|
+
}
|
|
15966
|
+
catch {
|
|
15967
|
+
// Try the next canonical/alias shape. Any total miss fails closed.
|
|
15968
|
+
}
|
|
15638
15969
|
}
|
|
15639
15970
|
return false;
|
|
15640
15971
|
};
|
|
15641
|
-
const isFactoryGithubWritebackPath = (path) => /^\/github\/repos\/[^/]+\/[^/]+\/(?:pull-requests\/factory-[^/]+\.json|refs\/(?:factory\.json|refs%2Fheads%2Ffactory%2F[^/]+\.json)|pulls\/[1-9]\d*\/close\.json)$/iu.test(path);
|
|
15642
15972
|
const isIssuePathInFactoryScope = async (mount, path, config) => {
|
|
15643
15973
|
try {
|
|
15644
15974
|
return isInFactoryScope(parseLinearIssue(path, (await mount.readFile(path)).content), config.safety);
|
|
@@ -16091,6 +16421,7 @@ const cloneTrackedAgent = (tracked) => ({
|
|
|
16091
16421
|
result: tracked.result ? { ...tracked.result } : undefined,
|
|
16092
16422
|
sessionRef: tracked.sessionRef,
|
|
16093
16423
|
unreachableWakeResumedSessionRef: tracked.unreachableWakeResumedSessionRef,
|
|
16424
|
+
releasedAtMs: tracked.releasedAtMs,
|
|
16094
16425
|
});
|
|
16095
16426
|
const durableBabysitterTrackedAgent = (session, capability = 'spawn:claude') => ({
|
|
16096
16427
|
spec: {
|
|
@@ -16167,7 +16498,11 @@ const lifecycleFromInFlightRecord = (record, runId, phase, updatedAtMs, pullRequ
|
|
|
16167
16498
|
decision: structuredClone(record.decision),
|
|
16168
16499
|
dryRun: record.dryRun,
|
|
16169
16500
|
phase,
|
|
16170
|
-
agents: [...record.agents].map(([name, tracked]) => ({
|
|
16501
|
+
agents: [...record.agents].map(([name, tracked]) => ({
|
|
16502
|
+
name,
|
|
16503
|
+
tracked: cloneTrackedAgent(tracked),
|
|
16504
|
+
...(tracked.releasedAtMs !== undefined ? { releasedAtMs: tracked.releasedAtMs } : {}),
|
|
16505
|
+
})),
|
|
16171
16506
|
invocationIds: [...record.invocationIds],
|
|
16172
16507
|
result: record.result ? structuredClone(record.result) : undefined,
|
|
16173
16508
|
...(record.dispatchClaim ? { dispatchClaim: { ...record.dispatchClaim } } : {}),
|
|
@@ -16182,7 +16517,13 @@ const inFlightRecordFromLifecycle = (lifecycle) => ({
|
|
|
16182
16517
|
issue: { ...lifecycle.issue },
|
|
16183
16518
|
decision: structuredClone(lifecycle.decision),
|
|
16184
16519
|
dryRun: lifecycle.dryRun,
|
|
16185
|
-
|
|
16520
|
+
// Release state has to survive takeover with the agents themselves. Without
|
|
16521
|
+
// it the rebuilt record reads a released placement as a live worker and the
|
|
16522
|
+
// spawn gate answers for a process that no longer exists.
|
|
16523
|
+
agents: new Map(lifecycle.agents.map((agent) => [agent.name, {
|
|
16524
|
+
...cloneTrackedAgent(agent.tracked),
|
|
16525
|
+
releasedAtMs: agent.releasedAtMs ?? agent.tracked.releasedAtMs,
|
|
16526
|
+
}])),
|
|
16186
16527
|
invocationIds: new Set(lifecycle.invocationIds),
|
|
16187
16528
|
result: lifecycle.result ? structuredClone(lifecycle.result) : undefined,
|
|
16188
16529
|
...(lifecycle.dispatchClaim ? { dispatchClaim: { ...lifecycle.dispatchClaim } } : {}),
|
|
@@ -16209,7 +16550,14 @@ const triageEscalationReason = (decision) => {
|
|
|
16209
16550
|
}
|
|
16210
16551
|
return `${reasons.join(' and ')}${decision.rationale ? `: ${decision.rationale}` : ''}`;
|
|
16211
16552
|
};
|
|
16212
|
-
|
|
16553
|
+
/**
|
|
16554
|
+
* The dispatch claim race: another writer changed the issue's live state
|
|
16555
|
+
* between this process reading it and writing back, so the dispatch was
|
|
16556
|
+
* abandoned. Exported so callers outside this module — notably the CLI, which
|
|
16557
|
+
* turns it into a distinct exit code — can recognize it by type rather than by
|
|
16558
|
+
* matching on `error.name`, and so tests can construct a genuine instance.
|
|
16559
|
+
*/
|
|
16560
|
+
export class LiveDispatchStateChangedError extends Error {
|
|
16213
16561
|
issueKey;
|
|
16214
16562
|
constructor(issueKey) {
|
|
16215
16563
|
super(`Live state changed before writeback for ${issueKey}`);
|
|
@@ -16217,6 +16565,10 @@ class LiveDispatchStateChangedError extends Error {
|
|
|
16217
16565
|
this.issueKey = issueKey;
|
|
16218
16566
|
}
|
|
16219
16567
|
}
|
|
16568
|
+
/** Whether a thrown value is a {@link LiveDispatchStateChangedError}. */
|
|
16569
|
+
export function isLiveDispatchStateChangedError(error) {
|
|
16570
|
+
return error instanceof LiveDispatchStateChangedError;
|
|
16571
|
+
}
|
|
16220
16572
|
const triageEscalationQuestion = (decision, issue) => {
|
|
16221
16573
|
const routedRepos = decision.routes.map((route) => route.repo).filter(Boolean);
|
|
16222
16574
|
const subject = issue?.title?.trim() || decision.issue.key;
|