@cat-factory/orchestration 0.129.6 → 0.129.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/container.d.ts +0 -1
- package/dist/container.d.ts.map +1 -1
- package/dist/container.js +83 -72
- package/dist/container.js.map +1 -1
- package/dist/modules/execution/CompanionController.d.ts +7 -0
- package/dist/modules/execution/CompanionController.d.ts.map +1 -1
- package/dist/modules/execution/CompanionController.js +61 -43
- package/dist/modules/execution/CompanionController.js.map +1 -1
- package/dist/modules/execution/RunDispatcher.d.ts +65 -0
- package/dist/modules/execution/RunDispatcher.d.ts.map +1 -1
- package/dist/modules/execution/RunDispatcher.js +260 -197
- package/dist/modules/execution/RunDispatcher.js.map +1 -1
- package/dist/modules/sandbox/SandboxRunService.d.ts.map +1 -1
- package/dist/modules/sandbox/SandboxRunService.js +22 -10
- package/dist/modules/sandbox/SandboxRunService.js.map +1 -1
- package/package.json +2 -2
|
@@ -527,65 +527,7 @@ export class RunDispatcher {
|
|
|
527
527
|
agentKind: step.agentKind,
|
|
528
528
|
});
|
|
529
529
|
if (update.state === 'running') {
|
|
530
|
-
|
|
531
|
-
// (defensive: a replay may have left the flag set). Surface live subtask progress
|
|
532
|
-
// (e.g. 3/8 todos done) without advancing the step. Only persist + emit when something
|
|
533
|
-
// actually changed so an idle poll doesn't churn storage or the event stream.
|
|
534
|
-
//
|
|
535
|
-
// Fold the poll's delta onto a target instance, returning whether anything changed. The
|
|
536
|
-
// captured `update` is the source, so re-applying on a freshly-reloaded instance is
|
|
537
|
-
// idempotent for the set-to-latest folds AND correct for the APPEND fold: the harness
|
|
538
|
-
// follow-up buffer is drain-on-read (`runner.ts`), so those streamed items exist ONLY on
|
|
539
|
-
// this `update` — abort-and-redrive would lose them, whereas re-applying under
|
|
540
|
-
// `mutateInstance` (which reloads a clean base each attempt) preserves them.
|
|
541
|
-
const applyRunningFold = async (target) => {
|
|
542
|
-
const s = target.steps[target.currentStep];
|
|
543
|
-
// The step advanced (or the job was superseded) under a concurrent write — nothing to fold.
|
|
544
|
-
if (!s || s.jobId !== step.jobId)
|
|
545
|
-
return false;
|
|
546
|
-
let changed = false;
|
|
547
|
-
if (this.applyContainerRunning(s, update))
|
|
548
|
-
changed = true;
|
|
549
|
-
if (this.applySubtaskProgress(s, update.subtasks))
|
|
550
|
-
changed = true;
|
|
551
|
-
// The transport reports WHICH backend served the job on the first poll (native host
|
|
552
|
-
// process vs. sandboxed container) — record it in the run diagnostics.
|
|
553
|
-
if (this.recordBackendDiagnostics(target, update.backend))
|
|
554
|
-
changed = true;
|
|
555
|
-
// Append any forward-looking items the Coder streamed since the last poll so the
|
|
556
|
-
// Follow-up companion lights up + accrues items LIVE while the container still runs.
|
|
557
|
-
if (this.followUpGate.appendStreamedFollowUps(s, update.followUps))
|
|
558
|
-
changed = true;
|
|
559
|
-
// Refresh the env projection so its status transitions (provisioning→ready→
|
|
560
|
-
// expired/torn_down) and any error stay live in the run details during the run.
|
|
561
|
-
if (await this.deployer.attachEnvironmentProjection(workspaceId, target.blockId, s))
|
|
562
|
-
changed = true;
|
|
563
|
-
return changed;
|
|
564
|
-
};
|
|
565
|
-
// Cheap pre-check against the loaded snapshot: skip the write entirely on an idle poll
|
|
566
|
-
// (the common case). The mutation is discarded — the authoritative write re-applies the
|
|
567
|
-
// same fold on fresh state under CAS below.
|
|
568
|
-
if (await applyRunningFold(instance)) {
|
|
569
|
-
try {
|
|
570
|
-
const persisted = await this.runStateMachine.mutateInstance(workspaceId, executionId, async (fresh) => {
|
|
571
|
-
await applyRunningFold(fresh);
|
|
572
|
-
});
|
|
573
|
-
// Progress-only fold (subtask ticks / streamed follow-ups): skip the per-run
|
|
574
|
-
// LLM-metrics GROUP BY so a live container's poll cadence doesn't re-aggregate
|
|
575
|
-
// the run on every tick. The rollup refreshes on the step-boundary/terminal emit.
|
|
576
|
-
await this.runStateMachine.emitInstance(workspaceId, persisted, { rollUpMetrics: false });
|
|
577
|
-
}
|
|
578
|
-
catch (error) {
|
|
579
|
-
// The run was cancelled/removed mid-poll (`NotFoundError`) or stayed hot-contended
|
|
580
|
-
// past the retry budget (`ConflictError`) — re-drive on fresh state rather than
|
|
581
|
-
// failing the run; the next entry no-ops on a gone/terminal run.
|
|
582
|
-
if (error instanceof NotFoundError || error instanceof ConflictError) {
|
|
583
|
-
throw new RunContendedError(executionId);
|
|
584
|
-
}
|
|
585
|
-
throw error;
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
return { kind: 'awaiting_job', jobId: step.jobId, stepIndex: instance.currentStep };
|
|
530
|
+
return this.handleRunningPoll(workspaceId, executionId, instance, update, step.jobId);
|
|
589
531
|
}
|
|
590
532
|
// A gate whose helper INVESTIGATES instead of fixing (post-release-health → on-call)
|
|
591
533
|
// declares a `resolveHelperCompletion` hook on its definition. When such a helper's job
|
|
@@ -594,34 +536,9 @@ export class RunDispatcher {
|
|
|
594
536
|
// budget) and finish the gate step with the output it returns. The gate raises its own
|
|
595
537
|
// `release_regression` notification + enriches any open incident inside the hook (from the
|
|
596
538
|
// signals stashed at escalation); the run then completes for a human to act out-of-band.
|
|
597
|
-
const
|
|
598
|
-
if (
|
|
599
|
-
|
|
600
|
-
(update.state === 'done' || update.state === 'failed')) {
|
|
601
|
-
const block = await this.blockRepository.get(workspaceId, instance.blockId);
|
|
602
|
-
step.jobId = undefined;
|
|
603
|
-
step.subtasks = undefined;
|
|
604
|
-
if (!block)
|
|
605
|
-
return { kind: 'noop' };
|
|
606
|
-
const isFinalStep = instance.currentStep === instance.steps.length - 1;
|
|
607
|
-
const jobResult = update.state === 'done'
|
|
608
|
-
? { state: 'done', result: update.result }
|
|
609
|
-
: { state: 'failed', error: update.error ?? null };
|
|
610
|
-
const resolution = await completionGate.resolveHelperCompletion({
|
|
611
|
-
workspaceId,
|
|
612
|
-
instance,
|
|
613
|
-
block,
|
|
614
|
-
step,
|
|
615
|
-
result: jobResult,
|
|
616
|
-
});
|
|
617
|
-
// Preserve the done-result's fields (usage metering etc.) while recording the gate's
|
|
618
|
-
// resolved output; a failed investigation has no result to carry.
|
|
619
|
-
const base = update.state === 'done' ? update.result : { output: '' };
|
|
620
|
-
return this.recordStepResult(workspaceId, instance, step, isFinalStep, {
|
|
621
|
-
...base,
|
|
622
|
-
output: resolution.output,
|
|
623
|
-
});
|
|
624
|
-
}
|
|
539
|
+
const investigated = await this.resolveInvestigateHelperCompletion(workspaceId, instance, step, update);
|
|
540
|
+
if (investigated)
|
|
541
|
+
return investigated;
|
|
625
542
|
// A polling gate step's in-flight job is its helper agent (ci-fixer /
|
|
626
543
|
// conflict-resolver), NOT the step's own work: when it finishes (or fails) we
|
|
627
544
|
// don't record a result or advance — we drop the handle, return the gate to
|
|
@@ -630,50 +547,7 @@ export class RunDispatcher {
|
|
|
630
547
|
// negative, so the next check re-dispatches (until the attempt budget is spent).
|
|
631
548
|
const reprobeGate = this.gateFor(step.agentKind);
|
|
632
549
|
if (reprobeGate) {
|
|
633
|
-
|
|
634
|
-
// reads it (the human-review gate replies to + RESOLVES the threads it handed the
|
|
635
|
-
// fixer, so the next probe counts them addressed). Run that side-effect hook first;
|
|
636
|
-
// it does NOT replace the re-probe (unlike resolveHelperCompletion).
|
|
637
|
-
if (reprobeGate.onHelperComplete && step.gate) {
|
|
638
|
-
const block = await this.blockRepository.get(workspaceId, instance.blockId);
|
|
639
|
-
if (block) {
|
|
640
|
-
const jobResult = update.state === 'done'
|
|
641
|
-
? { state: 'done', result: update.result }
|
|
642
|
-
: { state: 'failed', error: update.error ?? null };
|
|
643
|
-
await this.runInitiatorScope(instance.initiatedBy, () => reprobeGate.onHelperComplete({
|
|
644
|
-
workspaceId,
|
|
645
|
-
instance,
|
|
646
|
-
block,
|
|
647
|
-
step,
|
|
648
|
-
result: jobResult,
|
|
649
|
-
}));
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
// Record the just-finished helper attempt before re-probing. The gate's next
|
|
653
|
-
// precheck stays the source of truth for pass/fail, but the helper's own account
|
|
654
|
-
// (what it did, and for the conflict-resolver which files it left conflicting) is
|
|
655
|
-
// otherwise discarded here — leaving the gate window with only a bare attempt
|
|
656
|
-
// count. Capture it so the UI can show what each attempt tried.
|
|
657
|
-
if (step.gate) {
|
|
658
|
-
const attempt = recordGateAttempt(step.gate, update.state === 'done'
|
|
659
|
-
? { state: 'done', output: update.result.output ?? null }
|
|
660
|
-
: { state: 'failed', error: update.error ?? null }, this.clock.now());
|
|
661
|
-
step.gate.attemptLog = [...(step.gate.attemptLog ?? []), attempt];
|
|
662
|
-
// The conflicts gate's precheck carries no failure detail of its own (GitHub
|
|
663
|
-
// reports mergeability as a single bit), so surface the resolver's account as
|
|
664
|
-
// the gate's last failure summary. CI's probe already sets a richer summary
|
|
665
|
-
// (the red checks) — don't clobber it with the fixer's push note.
|
|
666
|
-
if (step.agentKind === CONFLICTS_AGENT_KIND && attempt.summary) {
|
|
667
|
-
step.gate.lastFailureSummary = attempt.summary;
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
step.jobId = undefined;
|
|
671
|
-
step.subtasks = undefined;
|
|
672
|
-
if (step.gate)
|
|
673
|
-
step.gate.phase = 'checking';
|
|
674
|
-
await this.runStateMachine.casPersist(workspaceId, instance);
|
|
675
|
-
await this.runStateMachine.emitInstance(workspaceId, instance);
|
|
676
|
-
return { kind: 'awaiting_gate', stepIndex: instance.currentStep };
|
|
550
|
+
return this.reprobeGateAfterHelper(reprobeGate, { workspaceId, instance, step, update });
|
|
677
551
|
}
|
|
678
552
|
// A `tester` step in its `fixing` phase has a Fixer job in flight, NOT the
|
|
679
553
|
// step's own work: when it finishes (or fails) we drop the handle, return to
|
|
@@ -768,6 +642,81 @@ export class RunDispatcher {
|
|
|
768
642
|
step.jobId = undefined;
|
|
769
643
|
return this.recordStepResult(workspaceId, instance, step, isFinalStep, update.result);
|
|
770
644
|
}
|
|
645
|
+
/**
|
|
646
|
+
* Handle a `running` poll: a successful poll proves the container is up, so surface live subtask
|
|
647
|
+
* progress (e.g. 3/8 todos) without advancing the step. Only persist + emit when something
|
|
648
|
+
* actually changed so an idle poll doesn't churn storage or the event stream. Folds the poll's
|
|
649
|
+
* delta via {@link applyRunningFold} — a cheap pre-check against the loaded snapshot, then the
|
|
650
|
+
* authoritative re-apply on fresh state under CAS (idempotent for the set-to-latest folds and
|
|
651
|
+
* correct for the drain-on-read follow-up append). Split from {@link pollAgentJobInner} to stay
|
|
652
|
+
* under the statement ceiling.
|
|
653
|
+
*/
|
|
654
|
+
async handleRunningPoll(workspaceId, executionId, instance, update, jobId) {
|
|
655
|
+
const foldCtx = { jobId, update, workspaceId };
|
|
656
|
+
// Cheap pre-check against the loaded snapshot: skip the write entirely on an idle poll
|
|
657
|
+
// (the common case). The mutation is discarded — the authoritative write re-applies the
|
|
658
|
+
// same fold on fresh state under CAS below.
|
|
659
|
+
if (await this.applyRunningFold(instance, foldCtx)) {
|
|
660
|
+
try {
|
|
661
|
+
const persisted = await this.runStateMachine.mutateInstance(workspaceId, executionId, async (fresh) => {
|
|
662
|
+
await this.applyRunningFold(fresh, foldCtx);
|
|
663
|
+
});
|
|
664
|
+
// Progress-only fold (subtask ticks / streamed follow-ups): skip the per-run
|
|
665
|
+
// LLM-metrics GROUP BY so a live container's poll cadence doesn't re-aggregate
|
|
666
|
+
// the run on every tick. The rollup refreshes on the step-boundary/terminal emit.
|
|
667
|
+
await this.runStateMachine.emitInstance(workspaceId, persisted, { rollUpMetrics: false });
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
// The run was cancelled/removed mid-poll (`NotFoundError`) or stayed hot-contended
|
|
671
|
+
// past the retry budget (`ConflictError`) — re-drive on fresh state rather than
|
|
672
|
+
// failing the run; the next entry no-ops on a gone/terminal run.
|
|
673
|
+
if (error instanceof NotFoundError || error instanceof ConflictError) {
|
|
674
|
+
throw new RunContendedError(executionId);
|
|
675
|
+
}
|
|
676
|
+
throw error;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return { kind: 'awaiting_job', jobId, stepIndex: instance.currentStep };
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* A gate whose helper INVESTIGATES instead of fixing (post-release-health → on-call) declares a
|
|
683
|
+
* `resolveHelperCompletion` hook. When such a helper's job settles — done OR failed — call the
|
|
684
|
+
* hook INSTEAD of re-probing the precheck (re-probing an investigate-don't-fix helper would just
|
|
685
|
+
* regress again and burn the budget) and finish the gate step with the output it returns. Returns
|
|
686
|
+
* the resulting {@link AdvanceResult}, or `null` when this branch doesn't apply (the caller falls
|
|
687
|
+
* through to the re-probe / other completion paths).
|
|
688
|
+
*/
|
|
689
|
+
async resolveInvestigateHelperCompletion(workspaceId, instance, step, update) {
|
|
690
|
+
const completionGate = this.gateFor(step.agentKind);
|
|
691
|
+
if (completionGate?.resolveHelperCompletion &&
|
|
692
|
+
step.gate?.phase === 'working' &&
|
|
693
|
+
(update.state === 'done' || update.state === 'failed')) {
|
|
694
|
+
const block = await this.blockRepository.get(workspaceId, instance.blockId);
|
|
695
|
+
step.jobId = undefined;
|
|
696
|
+
step.subtasks = undefined;
|
|
697
|
+
if (!block)
|
|
698
|
+
return { kind: 'noop' };
|
|
699
|
+
const isFinalStep = instance.currentStep === instance.steps.length - 1;
|
|
700
|
+
const jobResult = update.state === 'done'
|
|
701
|
+
? { state: 'done', result: update.result }
|
|
702
|
+
: { state: 'failed', error: update.error ?? null };
|
|
703
|
+
const resolution = await completionGate.resolveHelperCompletion({
|
|
704
|
+
workspaceId,
|
|
705
|
+
instance,
|
|
706
|
+
block,
|
|
707
|
+
step,
|
|
708
|
+
result: jobResult,
|
|
709
|
+
});
|
|
710
|
+
// Preserve the done-result's fields (usage metering etc.) while recording the gate's
|
|
711
|
+
// resolved output; a failed investigation has no result to carry.
|
|
712
|
+
const base = update.state === 'done' ? update.result : { output: '' };
|
|
713
|
+
return this.recordStepResult(workspaceId, instance, step, isFinalStep, {
|
|
714
|
+
...base,
|
|
715
|
+
output: resolution.output,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
771
720
|
/**
|
|
772
721
|
* Fold a running poll's container signals into `step.container`: a successful poll
|
|
773
722
|
* proves the container is `up`, and the harness's live phase (clone / agent / push)
|
|
@@ -775,6 +724,95 @@ export class RunDispatcher {
|
|
|
775
724
|
* so the caller only persists + emits on a real transition (an idle poll is a no-op).
|
|
776
725
|
* Prior id/url/phase are preserved when a poll omits them (drain-on-read semantics).
|
|
777
726
|
*/
|
|
727
|
+
/**
|
|
728
|
+
* Fold a running poll's live delta (container status/phase, subtask counts, backend, streamed
|
|
729
|
+
* follow-ups, env projection) onto `target`, returning whether anything changed. Idempotent for
|
|
730
|
+
* the set-to-latest folds and correct under CAS retry for the drain-on-read follow-up append —
|
|
731
|
+
* see the call site in {@link pollAgentJobInner}. A concurrent write that advanced the step (or
|
|
732
|
+
* superseded the job) makes it a no-op.
|
|
733
|
+
*/
|
|
734
|
+
async applyRunningFold(target, ctx) {
|
|
735
|
+
const { jobId, update, workspaceId } = ctx;
|
|
736
|
+
const s = target.steps[target.currentStep];
|
|
737
|
+
// The step advanced (or the job was superseded) under a concurrent write — nothing to fold.
|
|
738
|
+
if (!s || s.jobId !== jobId)
|
|
739
|
+
return false;
|
|
740
|
+
let changed = false;
|
|
741
|
+
if (this.applyContainerRunning(s, update))
|
|
742
|
+
changed = true;
|
|
743
|
+
if (this.applySubtaskProgress(s, update.subtasks))
|
|
744
|
+
changed = true;
|
|
745
|
+
// The transport reports WHICH backend served the job on the first poll (native host
|
|
746
|
+
// process vs. sandboxed container) — record it in the run diagnostics.
|
|
747
|
+
if (this.recordBackendDiagnostics(target, update.backend))
|
|
748
|
+
changed = true;
|
|
749
|
+
// Append any forward-looking items the Coder streamed since the last poll so the
|
|
750
|
+
// Follow-up companion lights up + accrues items LIVE while the container still runs.
|
|
751
|
+
if (this.followUpGate.appendStreamedFollowUps(s, update.followUps))
|
|
752
|
+
changed = true;
|
|
753
|
+
// Refresh the env projection so its status transitions (provisioning→ready→
|
|
754
|
+
// expired/torn_down) and any error stay live in the run details during the run.
|
|
755
|
+
if (await this.deployer.attachEnvironmentProjection(workspaceId, target.blockId, s)) {
|
|
756
|
+
changed = true;
|
|
757
|
+
}
|
|
758
|
+
return changed;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* A polling gate step's in-flight job is its helper agent (ci-fixer / conflict-resolver / the
|
|
762
|
+
* human-review fixer), NOT the step's own work: when it finishes (or fails) we don't record a
|
|
763
|
+
* result or advance — we run any deterministic post-helper bookkeeping hook, record the attempt,
|
|
764
|
+
* drop the handle, return the gate to `checking`, and re-run the precheck (the helper's push
|
|
765
|
+
* triggers a fresh CI run / updates mergeability). A helper that failed without pushing leaves the
|
|
766
|
+
* precheck negative, so the next check re-dispatches (until the attempt budget is spent). Split
|
|
767
|
+
* from {@link pollAgentJobInner} to keep it under the complexity ceiling.
|
|
768
|
+
*/
|
|
769
|
+
async reprobeGateAfterHelper(gate, ctx) {
|
|
770
|
+
const { workspaceId, instance, step, update } = ctx;
|
|
771
|
+
// A gate may need deterministic GitHub-side bookkeeping to land BEFORE the re-probe
|
|
772
|
+
// reads it (the human-review gate replies to + RESOLVES the threads it handed the
|
|
773
|
+
// fixer, so the next probe counts them addressed). Run that side-effect hook first;
|
|
774
|
+
// it does NOT replace the re-probe (unlike resolveHelperCompletion).
|
|
775
|
+
if (gate.onHelperComplete && step.gate) {
|
|
776
|
+
const block = await this.blockRepository.get(workspaceId, instance.blockId);
|
|
777
|
+
if (block) {
|
|
778
|
+
const jobResult = update.state === 'done'
|
|
779
|
+
? { state: 'done', result: update.result }
|
|
780
|
+
: { state: 'failed', error: update.error ?? null };
|
|
781
|
+
await this.runInitiatorScope(instance.initiatedBy, () => gate.onHelperComplete({
|
|
782
|
+
workspaceId,
|
|
783
|
+
instance,
|
|
784
|
+
block,
|
|
785
|
+
step,
|
|
786
|
+
result: jobResult,
|
|
787
|
+
}));
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
// Record the just-finished helper attempt before re-probing. The gate's next
|
|
791
|
+
// precheck stays the source of truth for pass/fail, but the helper's own account
|
|
792
|
+
// (what it did, and for the conflict-resolver which files it left conflicting) is
|
|
793
|
+
// otherwise discarded here — leaving the gate window with only a bare attempt
|
|
794
|
+
// count. Capture it so the UI can show what each attempt tried.
|
|
795
|
+
if (step.gate) {
|
|
796
|
+
const attempt = recordGateAttempt(step.gate, update.state === 'done'
|
|
797
|
+
? { state: 'done', output: update.result.output ?? null }
|
|
798
|
+
: { state: 'failed', error: update.error ?? null }, this.clock.now());
|
|
799
|
+
step.gate.attemptLog = [...(step.gate.attemptLog ?? []), attempt];
|
|
800
|
+
// The conflicts gate's precheck carries no failure detail of its own (GitHub
|
|
801
|
+
// reports mergeability as a single bit), so surface the resolver's account as
|
|
802
|
+
// the gate's last failure summary. CI's probe already sets a richer summary
|
|
803
|
+
// (the red checks) — don't clobber it with the fixer's push note.
|
|
804
|
+
if (step.agentKind === CONFLICTS_AGENT_KIND && attempt.summary) {
|
|
805
|
+
step.gate.lastFailureSummary = attempt.summary;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
step.jobId = undefined;
|
|
809
|
+
step.subtasks = undefined;
|
|
810
|
+
if (step.gate)
|
|
811
|
+
step.gate.phase = 'checking';
|
|
812
|
+
await this.runStateMachine.casPersist(workspaceId, instance);
|
|
813
|
+
await this.runStateMachine.emitInstance(workspaceId, instance);
|
|
814
|
+
return { kind: 'awaiting_gate', stepIndex: instance.currentStep };
|
|
815
|
+
}
|
|
778
816
|
applyContainerRunning(step, update) {
|
|
779
817
|
const prev = step.container ?? undefined;
|
|
780
818
|
const next = {
|
|
@@ -1026,12 +1064,7 @@ export class RunDispatcher {
|
|
|
1026
1064
|
options: [...result.decision.options],
|
|
1027
1065
|
chosen: null,
|
|
1028
1066
|
};
|
|
1029
|
-
this.
|
|
1030
|
-
instance.status = 'blocked';
|
|
1031
|
-
await this.runStateMachine.updateBlockProgress(workspaceId, instance, 'blocked');
|
|
1032
|
-
await this.runStateMachine.casPersist(workspaceId, instance);
|
|
1033
|
-
await this.runStateMachine.emitInstance(workspaceId, instance);
|
|
1034
|
-
return { kind: 'awaiting_decision', decisionId: step.decision.id };
|
|
1067
|
+
return this.parkStepAwaitingInput(workspaceId, instance, step, step.decision.id);
|
|
1035
1068
|
}
|
|
1036
1069
|
// Completion-path interceptors short-circuit before the normal finish/advance for the
|
|
1037
1070
|
// few kinds whose verdict drives run flow: a container-backed companion applies its
|
|
@@ -1072,30 +1105,7 @@ export class RunDispatcher {
|
|
|
1072
1105
|
// (service-connections phase 3) additionally reports the PRs it opened in the
|
|
1073
1106
|
// connected involved-service repos; record them beside the own-service PR (they may
|
|
1074
1107
|
// even arrive when the own service was a no-op and only a peer changed).
|
|
1075
|
-
|
|
1076
|
-
// Read the block before the update so we can tell whether this PR is newly
|
|
1077
|
-
// opened (vs. the same PR re-reported by a re-run/retry of the coder step).
|
|
1078
|
-
const priorBlock = this.issueWriteback
|
|
1079
|
-
? await this.blockRepository.get(workspaceId, instance.blockId)
|
|
1080
|
-
: null;
|
|
1081
|
-
await this.blockRepository.update(workspaceId, instance.blockId, {
|
|
1082
|
-
...(result.pullRequest ? { pullRequest: result.pullRequest } : {}),
|
|
1083
|
-
...(result.peerPullRequests?.length ? { peerPullRequests: result.peerPullRequests } : {}),
|
|
1084
|
-
});
|
|
1085
|
-
// Best-effort writeback: comment on the task's linked tracker issue(s) that a
|
|
1086
|
-
// PR opened. Only for the OWN-service PR, and only when it is newly recorded — a
|
|
1087
|
-
// retry that re-reports the same PR must not re-comment (the tracker comment is not
|
|
1088
|
-
// idempotent). Gated inside the provider by the workspace setting + per-task
|
|
1089
|
-
// override; fire-and-forget so a tracker outage never fails the run.
|
|
1090
|
-
if (this.issueWriteback &&
|
|
1091
|
-
priorBlock &&
|
|
1092
|
-
result.pullRequest &&
|
|
1093
|
-
priorBlock.pullRequest?.url !== result.pullRequest.url) {
|
|
1094
|
-
await this.issueWriteback
|
|
1095
|
-
.onPullRequestOpened(workspaceId, priorBlock, result.pullRequest)
|
|
1096
|
-
.catch(() => { });
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1108
|
+
await this.recordOpenedPullRequests(workspaceId, instance, result);
|
|
1099
1109
|
// Run any POST-COMPLETION resolver registered for this step kind (blueprint/spec
|
|
1100
1110
|
// ingestion, task-estimate persistence). It reshapes the agent's structured result into
|
|
1101
1111
|
// domain state and may replace `step.output` (the estimator's readable summary). Its
|
|
@@ -1103,19 +1113,7 @@ export class RunDispatcher {
|
|
|
1103
1113
|
// reviewable-output rendering and the follow-up/approval gates read `step.output`, so it
|
|
1104
1114
|
// sits exactly where the old inline ingestion branches did. See
|
|
1105
1115
|
// {@link buildStepResolverRegistry} and {@link StepCompletionResolver.phase}.
|
|
1106
|
-
|
|
1107
|
-
if (postCompletionResolver?.phase === 'post-completion' &&
|
|
1108
|
-
(postCompletionResolver.applies?.(result) ?? true)) {
|
|
1109
|
-
const resolution = await postCompletionResolver.resolve({
|
|
1110
|
-
workspaceId,
|
|
1111
|
-
instance,
|
|
1112
|
-
step,
|
|
1113
|
-
result,
|
|
1114
|
-
isFinalStep,
|
|
1115
|
-
});
|
|
1116
|
-
if (resolution?.output !== undefined)
|
|
1117
|
-
step.output = resolution.output;
|
|
1118
|
-
}
|
|
1116
|
+
await this.applyPostCompletionResolver(workspaceId, instance, step, result, isFinalStep);
|
|
1119
1117
|
// A producer that emits a STRUCTURED ARTIFACT (the spec doc, the blueprint tree, …)
|
|
1120
1118
|
// returns its raw Pi transcript summary as `result.output` — useless for review.
|
|
1121
1119
|
// Replace the step's reviewable output with a rendering of the artifact ITSELF, so
|
|
@@ -1150,12 +1148,7 @@ export class RunDispatcher {
|
|
|
1150
1148
|
status: 'pending',
|
|
1151
1149
|
proposal: step.output,
|
|
1152
1150
|
};
|
|
1153
|
-
this.
|
|
1154
|
-
instance.status = 'blocked';
|
|
1155
|
-
await this.runStateMachine.updateBlockProgress(workspaceId, instance, 'blocked');
|
|
1156
|
-
await this.runStateMachine.casPersist(workspaceId, instance);
|
|
1157
|
-
await this.runStateMachine.emitInstance(workspaceId, instance);
|
|
1158
|
-
return { kind: 'awaiting_decision', decisionId: step.approval.id };
|
|
1151
|
+
return this.parkStepAwaitingInput(workspaceId, instance, step, step.approval.id);
|
|
1159
1152
|
}
|
|
1160
1153
|
// Persist the agent's reported confidence whenever a step reports it, for board
|
|
1161
1154
|
// transparency. Position-independent: it must NOT be tied to the final step, since a
|
|
@@ -1171,23 +1164,7 @@ export class RunDispatcher {
|
|
|
1171
1164
|
// — so inserting a later step (post-release-health) can't silently disable it. A
|
|
1172
1165
|
// resolver that owns the block's terminal status (the merger sets `done`/`pr_ready`)
|
|
1173
1166
|
// tells `finalizeBlock` to leave it alone.
|
|
1174
|
-
const
|
|
1175
|
-
let resolverOwnsTerminalStatus = false;
|
|
1176
|
-
if (resolver &&
|
|
1177
|
-
(resolver.phase ?? 'terminal') === 'terminal' &&
|
|
1178
|
-
(resolver.applies?.(result) ?? true)) {
|
|
1179
|
-
const resolution = await resolver.resolve({
|
|
1180
|
-
workspaceId,
|
|
1181
|
-
instance,
|
|
1182
|
-
step,
|
|
1183
|
-
result,
|
|
1184
|
-
isFinalStep,
|
|
1185
|
-
});
|
|
1186
|
-
if (resolution?.output !== undefined)
|
|
1187
|
-
step.output = resolution.output;
|
|
1188
|
-
if (resolution?.ownsTerminalStatus)
|
|
1189
|
-
resolverOwnsTerminalStatus = true;
|
|
1190
|
-
}
|
|
1167
|
+
const resolverOwnsTerminalStatus = await this.applyTerminalStepResolver(workspaceId, instance, step, result, isFinalStep);
|
|
1191
1168
|
// A registered custom kind's POST-ops run deterministic backend repo work from the
|
|
1192
1169
|
// agent's structured result (coerce its JSON, render artifact files, commit them via
|
|
1193
1170
|
// the checkout-free RepoFiles port — the blueprint/spec rendering that used to live in
|
|
@@ -1228,6 +1205,92 @@ export class RunDispatcher {
|
|
|
1228
1205
|
await this.runStateMachine.emitInstance(workspaceId, instance);
|
|
1229
1206
|
return { kind: 'continue' };
|
|
1230
1207
|
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Park a step on the durable decision-wait: pause it for input, flip the run + block to
|
|
1210
|
+
* `blocked`, persist under CAS, emit, and report `awaiting_decision` keyed by `decisionId`.
|
|
1211
|
+
* Shared by the raised-decision and human-approval branches of {@link recordStepResult}.
|
|
1212
|
+
*/
|
|
1213
|
+
async parkStepAwaitingInput(workspaceId, instance, step, decisionId) {
|
|
1214
|
+
this.stepGraph.pauseStepForInput(step);
|
|
1215
|
+
instance.status = 'blocked';
|
|
1216
|
+
await this.runStateMachine.updateBlockProgress(workspaceId, instance, 'blocked');
|
|
1217
|
+
await this.runStateMachine.casPersist(workspaceId, instance);
|
|
1218
|
+
await this.runStateMachine.emitInstance(workspaceId, instance);
|
|
1219
|
+
return { kind: 'awaiting_decision', decisionId };
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Record any PR(s) the step opened onto the block (its own-service PR + any peer-service PRs),
|
|
1223
|
+
* and best-effort write back to the task's linked tracker issue(s) when the own-service PR is
|
|
1224
|
+
* NEWLY opened (a retry that re-reports the same PR must not re-comment). Split from
|
|
1225
|
+
* {@link recordStepResult} to keep it under the statement ceiling; a no-op when no PR was opened.
|
|
1226
|
+
*/
|
|
1227
|
+
async recordOpenedPullRequests(workspaceId, instance, result) {
|
|
1228
|
+
if (!(result.pullRequest || result.peerPullRequests?.length))
|
|
1229
|
+
return;
|
|
1230
|
+
// Read the block before the update so we can tell whether this PR is newly
|
|
1231
|
+
// opened (vs. the same PR re-reported by a re-run/retry of the coder step).
|
|
1232
|
+
const priorBlock = this.issueWriteback
|
|
1233
|
+
? await this.blockRepository.get(workspaceId, instance.blockId)
|
|
1234
|
+
: null;
|
|
1235
|
+
await this.blockRepository.update(workspaceId, instance.blockId, {
|
|
1236
|
+
...(result.pullRequest ? { pullRequest: result.pullRequest } : {}),
|
|
1237
|
+
...(result.peerPullRequests?.length ? { peerPullRequests: result.peerPullRequests } : {}),
|
|
1238
|
+
});
|
|
1239
|
+
// Best-effort writeback: comment on the task's linked tracker issue(s) that a
|
|
1240
|
+
// PR opened. Only for the OWN-service PR, and only when it is newly recorded — a
|
|
1241
|
+
// retry that re-reports the same PR must not re-comment (the tracker comment is not
|
|
1242
|
+
// idempotent). Gated inside the provider by the workspace setting + per-task
|
|
1243
|
+
// override; fire-and-forget so a tracker outage never fails the run.
|
|
1244
|
+
if (this.issueWriteback &&
|
|
1245
|
+
priorBlock &&
|
|
1246
|
+
result.pullRequest &&
|
|
1247
|
+
priorBlock.pullRequest?.url !== result.pullRequest.url) {
|
|
1248
|
+
await this.issueWriteback
|
|
1249
|
+
.onPullRequestOpened(workspaceId, priorBlock, result.pullRequest)
|
|
1250
|
+
.catch(() => { });
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Run any POST-COMPLETION resolver registered for this step kind (blueprint/spec ingestion,
|
|
1255
|
+
* task-estimate persistence). It reshapes the agent's structured result into domain state and may
|
|
1256
|
+
* replace `step.output`. A no-op when no post-completion resolver applies. See
|
|
1257
|
+
* {@link buildStepResolverRegistry} and {@link StepCompletionResolver.phase}.
|
|
1258
|
+
*/
|
|
1259
|
+
async applyPostCompletionResolver(workspaceId, instance, step, result, isFinalStep) {
|
|
1260
|
+
const postCompletionResolver = this.stepResolverFor(step.agentKind);
|
|
1261
|
+
if (postCompletionResolver?.phase !== 'post-completion' ||
|
|
1262
|
+
!(postCompletionResolver.applies?.(result) ?? true)) {
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
const resolution = await postCompletionResolver.resolve({
|
|
1266
|
+
workspaceId,
|
|
1267
|
+
instance,
|
|
1268
|
+
step,
|
|
1269
|
+
result,
|
|
1270
|
+
isFinalStep,
|
|
1271
|
+
});
|
|
1272
|
+
if (resolution?.output !== undefined)
|
|
1273
|
+
step.output = resolution.output;
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Run any DETERMINISTIC terminal-phase resolver for this step kind (e.g. the merger performs the
|
|
1277
|
+
* real GitHub merge with backend-held credentials), mutating `step.output` when it reshapes it.
|
|
1278
|
+
* Position-independent: it fires whenever the step finishes, not only when it's last. Returns
|
|
1279
|
+
* whether the resolver OWNS the block's terminal status (the merger sets `done`/`pr_ready`), so
|
|
1280
|
+
* the advance/finalize path leaves that status alone rather than clobbering it to `in_progress`.
|
|
1281
|
+
*/
|
|
1282
|
+
async applyTerminalStepResolver(workspaceId, instance, step, result, isFinalStep) {
|
|
1283
|
+
const resolver = this.stepResolverFor(step.agentKind);
|
|
1284
|
+
if (!resolver ||
|
|
1285
|
+
(resolver.phase ?? 'terminal') !== 'terminal' ||
|
|
1286
|
+
!(resolver.applies?.(result) ?? true)) {
|
|
1287
|
+
return false;
|
|
1288
|
+
}
|
|
1289
|
+
const resolution = await resolver.resolve({ workspaceId, instance, step, result, isFinalStep });
|
|
1290
|
+
if (resolution?.output !== undefined)
|
|
1291
|
+
step.output = resolution.output;
|
|
1292
|
+
return resolution?.ownsTerminalStatus ?? false;
|
|
1293
|
+
}
|
|
1231
1294
|
/**
|
|
1232
1295
|
* File a tracking issue/ticket for a `tracker` step from the preceding `analysis`
|
|
1233
1296
|
* output. Non-LLM and best-effort: when no provider is wired or none is configured
|