agent-dealer 1.1.2 → 1.1.3
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/bundle/server/dist/adapters/git-worktree.js +87 -3
- package/bundle/server/dist/adapters/git-worktree.test.js +160 -1
- package/bundle/server/dist/coordinator/auto-merge.js +10 -5
- package/bundle/server/dist/coordinator/commands.js +207 -28
- package/bundle/server/dist/coordinator/developer-effect.js +20 -1
- package/bundle/server/dist/coordinator/developer-effect.test.js +148 -1
- package/bundle/server/dist/coordinator/external-merge-close.test.js +290 -0
- package/bundle/server/dist/coordinator/external-merge.js +67 -0
- package/bundle/server/dist/coordinator/failure-reason.js +2 -0
- package/bundle/server/dist/coordinator/human-resolution.js +22 -1
- package/bundle/server/dist/coordinator/merge-failure-retry.test.js +277 -0
- package/bundle/server/dist/coordinator/routing.js +4 -0
- package/bundle/server/dist/coordinator/routing.test.js +14 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +39 -0
- package/bundle/server/dist/coordinator/worker-loop.js +2 -1
- package/bundle/server/dist/repository/issues.js +19 -0
- package/bundle/server/dist/routes/issues.js +2 -2
- package/bundle/server/package.json +2 -2
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { canTransitionIssue } from "@agent-dealer/shared";
|
|
1
2
|
import { getDb } from "../db/index.js";
|
|
2
3
|
import { getIssue, incrementIssueRound, incrementIssueInfraAttempts, resetIssueInfraAttempts, grantReviewRetry, transitionIssue, } from "../repository/issues.js";
|
|
3
4
|
import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstance, getWorkflowInstance, listWorkflowEventsForIssue, startWorkflowInstance, WorkflowAlreadyActiveError, } from "../repository/workflow-events.js";
|
|
@@ -15,10 +16,11 @@ import { workerSessionPayload } from "./session-progress.js";
|
|
|
15
16
|
import { reasonForWorkerFailedEvent } from "./failure-reason.js";
|
|
16
17
|
import { routeDeveloperOutcome, routeReviewerOutcome, } from "./routing.js";
|
|
17
18
|
import { projectDeveloperRoute, projectReviewerRoute } from "./projection.js";
|
|
18
|
-
import { parseHumanResolution, resolveHumanActionOutcome } from "./human-resolution.js";
|
|
19
|
+
import { MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS, parseHumanResolution, resolveHumanActionOutcome, } from "./human-resolution.js";
|
|
19
20
|
import { AUTO_MERGE_INTENT, finalizeAutoMerge } from "./auto-merge.js";
|
|
21
|
+
import { externalMergeStateForIssue } from "./external-merge.js";
|
|
20
22
|
import { detectNonConvergence, formatNonConvergenceReason, reviewHistoryFromEvents, } from "./non-convergence.js";
|
|
21
|
-
import { capEscalationEvents, deferLeasedWorkItemForDeckOutage, deferLeasedWorkItemForUsageCap, formatCapEscalationReason, usageCapDeferralStartedAt, } from "./usage-cap-defer.js";
|
|
23
|
+
import { capEscalationEvents, deferLeasedWorkItemForBaseFetch, deferLeasedWorkItemForDeckOutage, deferLeasedWorkItemForUsageCap, formatCapEscalationReason, usageCapDeferralStartedAt, } from "./usage-cap-defer.js";
|
|
22
24
|
import { markQueueEntryAdmitted } from "../repository/queue-entries.js";
|
|
23
25
|
import { getWorkflow } from "./workflows/registry.js";
|
|
24
26
|
import { DEV_REVIEWER_V1_VERSION } from "./workflows/dev-reviewer-v1.js";
|
|
@@ -284,6 +286,11 @@ export async function applyCompletion(workItemId, leaseToken, outcome) {
|
|
|
284
286
|
if (outcome.kind === "deck_unavailable") {
|
|
285
287
|
return applyDeckOutageCompletion(workItemId, leaseToken, outcome);
|
|
286
288
|
}
|
|
289
|
+
// NOT-197: same shape as a deck outage — the start never happened (no branch, no
|
|
290
|
+
// spawn), so the work item waits for the network instead of spending an attempt.
|
|
291
|
+
if (outcome.kind === "base_fetch_failed") {
|
|
292
|
+
return applyBaseFetchDeferralCompletion(workItemId, leaseToken, outcome);
|
|
293
|
+
}
|
|
287
294
|
const routed = getDb().transaction(() => {
|
|
288
295
|
const before = getWorkItem(workItemId);
|
|
289
296
|
if (!before)
|
|
@@ -368,6 +375,10 @@ function applyUsageCapCompletion(workItemId, leaseToken, cap) {
|
|
|
368
375
|
function applyDeckOutageCompletion(workItemId, leaseToken, outage) {
|
|
369
376
|
return applyDeferralCompletion(workItemId, leaseToken, outage, (item, issue, instance) => deferLeasedWorkItemForDeckOutage(item, leaseToken, outage, issue, instance));
|
|
370
377
|
}
|
|
378
|
+
/** NOT-197: no escalation arm — the item waits for the network for as long as it takes. */
|
|
379
|
+
function applyBaseFetchDeferralCompletion(workItemId, leaseToken, failure) {
|
|
380
|
+
return applyDeferralCompletion(workItemId, leaseToken, failure, (item, issue, instance) => deferLeasedWorkItemForBaseFetch(item, leaseToken, failure, issue, instance));
|
|
381
|
+
}
|
|
371
382
|
/**
|
|
372
383
|
* Deferral ceiling exceeded — escalate with cap evidence without spending infra attempts.
|
|
373
384
|
* Used when applyCompletion or the worker loop cannot defer any longer.
|
|
@@ -728,13 +739,30 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
|
|
|
728
739
|
}
|
|
729
740
|
return base;
|
|
730
741
|
}
|
|
731
|
-
|
|
742
|
+
/**
|
|
743
|
+
* NOT-194: true when the action is a merge-failure escalation (evidence carries
|
|
744
|
+
* `mergeFailure: true`). Pre-NOT-194 open merge-failure actions have no such evidence and
|
|
745
|
+
* read as ordinary policy_escalations — that is what keeps their `resume` path working.
|
|
746
|
+
*/
|
|
747
|
+
export function isMergeFailureAction(action) {
|
|
748
|
+
if (!action.evidenceJson)
|
|
749
|
+
return false;
|
|
750
|
+
try {
|
|
751
|
+
return JSON.parse(action.evidenceJson)[MERGE_FAILURE_EVIDENCE_KEY] === true;
|
|
752
|
+
}
|
|
753
|
+
catch {
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
function questionFor(actionType, reason, resumeAsReviewer = false, mergeFailure = false) {
|
|
732
758
|
switch (actionType) {
|
|
733
759
|
case "final_review":
|
|
734
760
|
return "Merge this work, send it back for another repair round, or close it?";
|
|
735
761
|
case "attempts_exhausted":
|
|
736
762
|
return "The review-round limit is reached. Retry with a fresh round, or close the issue?";
|
|
737
763
|
case "policy_escalation":
|
|
764
|
+
if (mergeFailure)
|
|
765
|
+
return `${reason} Retry the merge, queue another repair round, or close the issue?`;
|
|
738
766
|
return resumeAsReviewer
|
|
739
767
|
? `${reason} Retry the review, or close the issue?`
|
|
740
768
|
: `${reason} Resume development, or close the issue?`;
|
|
@@ -760,7 +788,7 @@ function questionFor(actionType, reason, resumeAsReviewer = false) {
|
|
|
760
788
|
/** Exported for db/migrate-to-issues.ts, which must populate the same response options on
|
|
761
789
|
* a migrated final_review/attempts_exhausted action — otherwise the UI parses a null
|
|
762
790
|
* response_options_json as no options and renders nothing to resolve it with. */
|
|
763
|
-
export function responseOptionsFor(actionType, resumeAsReviewer = false) {
|
|
791
|
+
export function responseOptionsFor(actionType, resumeAsReviewer = false, opts = {}) {
|
|
764
792
|
switch (actionType) {
|
|
765
793
|
case "final_review":
|
|
766
794
|
return [
|
|
@@ -774,6 +802,9 @@ export function responseOptionsFor(actionType, resumeAsReviewer = false) {
|
|
|
774
802
|
{ choice: "close", label: "Close" },
|
|
775
803
|
];
|
|
776
804
|
case "policy_escalation":
|
|
805
|
+
// NOT-194: a merge failure after approval offers retry/repair/close — never resume.
|
|
806
|
+
if (opts.mergeFailure)
|
|
807
|
+
return [...MERGE_FAILURE_RESPONSE_OPTIONS];
|
|
777
808
|
return [
|
|
778
809
|
{ choice: "resume", label: resumeAsReviewer ? "Retry review" : "Resume development" },
|
|
779
810
|
{ choice: "close", label: "Close" },
|
|
@@ -809,8 +840,14 @@ function parseContinuationPreview(json) {
|
|
|
809
840
|
* `final_review:complete` parks for undraft+merge (same finalize as auto-merge) and
|
|
810
841
|
* returns `pendingMerge: true` — callers that need the merge to finish must use
|
|
811
842
|
* `resolveHumanActionAndAdvanceAsync` (HTTP/CLI) rather than this sync entry point alone.
|
|
843
|
+
*
|
|
844
|
+
* NOT-196: a `close` choice finishes as `done` instead of `closed` when
|
|
845
|
+
* `opts.externalMergeState` is "merged" — the PR landed outside Dealer, so the work is
|
|
846
|
+
* landed and dependents must be released. Any other state (or no pre-read at all)
|
|
847
|
+
* keeps today's `closed`. The `gh` read itself lives in the async wrapper
|
|
848
|
+
* (`resolveHumanActionAndAdvanceAsync`), never inside this transaction.
|
|
812
849
|
*/
|
|
813
|
-
export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice) {
|
|
850
|
+
export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts) {
|
|
814
851
|
const action = getHumanAction(actionId);
|
|
815
852
|
if (!action)
|
|
816
853
|
return { ok: false, code: 404, error: "Human action not found" };
|
|
@@ -820,6 +857,21 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice) {
|
|
|
820
857
|
if (!resolution) {
|
|
821
858
|
return { ok: false, code: 400, error: `Invalid choice "${choice}" for ${action.actionType}` };
|
|
822
859
|
}
|
|
860
|
+
// NOT-194: narrow policy_escalation choices per action. A merge-failure action offers
|
|
861
|
+
// retry_merge/repair/close only (resume would re-run development on approved work);
|
|
862
|
+
// every other policy_escalation keeps resume/close only. Pre-NOT-194 open merge-failure
|
|
863
|
+
// actions carry no mergeFailure evidence, so they still accept resume here.
|
|
864
|
+
if (resolution.actionType === "policy_escalation") {
|
|
865
|
+
const mergeFailure = isMergeFailureAction(action);
|
|
866
|
+
const allowed = mergeFailure ? ["retry_merge", "repair", "close"] : ["resume", "close"];
|
|
867
|
+
if (!allowed.includes(resolution.choice)) {
|
|
868
|
+
return {
|
|
869
|
+
ok: false,
|
|
870
|
+
code: 400,
|
|
871
|
+
error: `Invalid choice "${choice}" for ${mergeFailure ? "a merge-failure" : "this"} policy_escalation`,
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
}
|
|
823
875
|
// parseHumanResolution already rejects every Run-scoped action type (NOT-95) above, so
|
|
824
876
|
// every action reaching here is Issue-scoped — this narrows action.issueId for TS.
|
|
825
877
|
if (!action.issueId)
|
|
@@ -830,9 +882,12 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice) {
|
|
|
830
882
|
const instance = getActiveWorkflowInstance(action.issueId);
|
|
831
883
|
// NOT-102 / NOT-150: human Merge (or legacy "complete") must undraft+merge.
|
|
832
884
|
// Park like auto-merge, then the async wrapper runs finalizeAutoMerge outside this txn.
|
|
885
|
+
// NOT-194: a merge-failure retry_merge parks the same way — the old action is resolved
|
|
886
|
+
// first, so a second failure escalates exactly one fresh action with the new reason.
|
|
833
887
|
if (instance &&
|
|
834
|
-
resolution.actionType === "final_review" &&
|
|
835
|
-
|
|
888
|
+
((resolution.actionType === "final_review" &&
|
|
889
|
+
(resolution.choice === "merge" || resolution.choice === "complete")) ||
|
|
890
|
+
(resolution.actionType === "policy_escalation" && resolution.choice === "retry_merge"))) {
|
|
836
891
|
return getDb().transaction(() => {
|
|
837
892
|
resolveHumanAction(actionId, resolvedBy, { choice });
|
|
838
893
|
appendWorkflowEvent({
|
|
@@ -841,9 +896,9 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice) {
|
|
|
841
896
|
type: "human_action.resolved",
|
|
842
897
|
actorType: "human",
|
|
843
898
|
actorRef: resolvedBy,
|
|
844
|
-
stage: "final_review",
|
|
899
|
+
stage: resolution.actionType === "final_review" ? "final_review" : issue.status,
|
|
845
900
|
round: issue.currentRound,
|
|
846
|
-
payload: { actionType:
|
|
901
|
+
payload: { actionType: action.actionType, choice: resolution.choice, pendingMerge: true },
|
|
847
902
|
});
|
|
848
903
|
transitionIssue(issue.id, "final_review", {
|
|
849
904
|
currentOwner: "system",
|
|
@@ -929,37 +984,90 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice) {
|
|
|
929
984
|
? issue.currentRound + 1
|
|
930
985
|
: issue.currentRound;
|
|
931
986
|
const resumeStatus = resumeAsReviewer ? "reviewing" : outcome.issueStatus;
|
|
987
|
+
// NOT-196: a close on an issue whose PR is MERGED on GitHub lands as done — the
|
|
988
|
+
// code is on the base branch, so dependents must be released. Only a confirmed merge
|
|
989
|
+
// upgrades; open / closed-unmerged / unreadable ("unknown") / no pre-read all stay
|
|
990
|
+
// closed. The PR number gates the upgrade so a merged state can never apply to an
|
|
991
|
+
// issue that never had a PR, and the status-machine gate keeps it to statuses with
|
|
992
|
+
// a →done edge (close actions park at needs_human/final_review, both of which have
|
|
993
|
+
// one) so the upgrade can never throw inside the transaction.
|
|
994
|
+
const prMerged = outcome.workflowOutcome === "closed" && opts?.externalMergeState === "merged" && issue.prNumber != null;
|
|
995
|
+
const externallyMerged = prMerged && canTransitionIssue(issue.status, "done");
|
|
996
|
+
const closeHasPr = outcome.workflowOutcome === "closed" && issue.prNumber != null;
|
|
997
|
+
// Extra audit keys on the close resolution only — every other outcome keeps today's
|
|
998
|
+
// exact payload.
|
|
999
|
+
const closeResolutionExtra = !closeHasPr
|
|
1000
|
+
? {}
|
|
1001
|
+
: externallyMerged
|
|
1002
|
+
? { prNumber: issue.prNumber, prState: "MERGED", externalMerge: true }
|
|
1003
|
+
: opts?.externalMergeState === "unknown"
|
|
1004
|
+
? { prNumber: issue.prNumber, prMergeStateUnknown: true }
|
|
1005
|
+
: prMerged
|
|
1006
|
+
? {
|
|
1007
|
+
prNumber: issue.prNumber,
|
|
1008
|
+
prState: "MERGED",
|
|
1009
|
+
doneTransitionBlocked: `no ${issue.status} → done edge`,
|
|
1010
|
+
}
|
|
1011
|
+
: {};
|
|
932
1012
|
return getDb().transaction(() => {
|
|
933
1013
|
resolveHumanAction(actionId, resolvedBy, { choice });
|
|
934
|
-
// stage must be the status this resolution actually lands on (
|
|
1014
|
+
// stage must be the status this resolution actually lands on (finalStatus), not the
|
|
935
1015
|
// generic developer-resume outcome.issueStatus — otherwise a reviewer resume's own
|
|
936
1016
|
// human_action.resolved/repair.started events would be recorded under "developing"
|
|
937
1017
|
// even though the issue transitions to "reviewing".
|
|
938
|
-
const
|
|
1018
|
+
const finalStatus = externallyMerged ? "done" : resumeStatus;
|
|
1019
|
+
const ev = eventEmitter(issue, instance, null, finalStatus, issue.currentRound);
|
|
939
1020
|
ev.emit("human_action.resolved", {
|
|
940
1021
|
actorType: "human",
|
|
941
|
-
payload: { actionType: action.actionType, choice },
|
|
1022
|
+
payload: { actionType: action.actionType, choice, ...closeResolutionExtra },
|
|
942
1023
|
});
|
|
943
|
-
transitionIssue(issue.id,
|
|
944
|
-
currentOwner:
|
|
945
|
-
currentIntent:
|
|
946
|
-
?
|
|
947
|
-
|
|
1024
|
+
transitionIssue(issue.id, finalStatus, {
|
|
1025
|
+
currentOwner: finalStatus === "done" || finalStatus === "closed" ? "system" : resumeAsReviewer ? "reviewer" : "developer",
|
|
1026
|
+
currentIntent: finalStatus === "done"
|
|
1027
|
+
? externallyMerged
|
|
1028
|
+
? "Merged outside Dealer"
|
|
1029
|
+
: "Complete"
|
|
1030
|
+
: finalStatus === "closed"
|
|
948
1031
|
? "Closed"
|
|
949
1032
|
: resumeAsReviewer
|
|
950
1033
|
? `Reviewer re-evaluating at ${continuation.resumeHeadSha.slice(0, 8)}`
|
|
951
1034
|
: `Developer implementing round ${nextRound}`,
|
|
952
1035
|
});
|
|
953
1036
|
if (outcome.workflowOutcome) {
|
|
954
|
-
|
|
955
|
-
|
|
1037
|
+
const finalOutcome = externallyMerged ? "done" : outcome.workflowOutcome;
|
|
1038
|
+
completeWorkflowInstance(instance.id, finalOutcome);
|
|
1039
|
+
if (externallyMerged) {
|
|
1040
|
+
ev.emit("issue.completed", {
|
|
1041
|
+
payload: { externalMerge: true, prNumber: issue.prNumber, prState: "MERGED" },
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
else if (finalOutcome === "done") {
|
|
1045
|
+
ev.emit("issue.completed");
|
|
1046
|
+
}
|
|
1047
|
+
else if (opts?.externalMergeState === "unknown" && issue.prNumber != null) {
|
|
1048
|
+
ev.emit("issue.closed", {
|
|
1049
|
+
payload: { prNumber: issue.prNumber, prMergeStateUnknown: true },
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
else if (prMerged) {
|
|
1053
|
+
ev.emit("issue.closed", {
|
|
1054
|
+
payload: {
|
|
1055
|
+
prNumber: issue.prNumber,
|
|
1056
|
+
prState: "MERGED",
|
|
1057
|
+
doneTransitionBlocked: `no ${issue.status} → done edge`,
|
|
1058
|
+
},
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
else {
|
|
1062
|
+
ev.emit("issue.closed");
|
|
1063
|
+
}
|
|
956
1064
|
return {
|
|
957
1065
|
ok: true,
|
|
958
1066
|
issueStatus: getIssue(issue.id).status,
|
|
959
1067
|
nextWorkItemId: null,
|
|
960
1068
|
instanceCompleted: true,
|
|
961
1069
|
restarted: false,
|
|
962
|
-
triggerReflect: outcome.triggerReflect === true,
|
|
1070
|
+
triggerReflect: outcome.triggerReflect === true || externallyMerged,
|
|
963
1071
|
};
|
|
964
1072
|
}
|
|
965
1073
|
// Another round: spend the budget this resolution's roundKind names, then queue the
|
|
@@ -1029,7 +1137,12 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice) {
|
|
|
1029
1137
|
* parks for merge, awaits undraft+merge (`finalizeAutoMerge`) before returning.
|
|
1030
1138
|
*/
|
|
1031
1139
|
export async function resolveHumanActionAndAdvanceAsync(actionId, resolvedBy, choice) {
|
|
1032
|
-
|
|
1140
|
+
// NOT-196: the `gh` PR-state read runs here, outside any DB transaction — the sync
|
|
1141
|
+
// core below only consumes the pre-read state. Only close choices on issues with a
|
|
1142
|
+
// PR number pay for the call; anything unreadable resolves to "unknown" (or
|
|
1143
|
+
// undefined when there is nothing to check), both of which keep today's `closed`.
|
|
1144
|
+
const externalMergeState = await preReadExternalMergeState(actionId, choice);
|
|
1145
|
+
const result = resolveHumanActionAndAdvance(actionId, resolvedBy, choice, { externalMergeState });
|
|
1033
1146
|
if (!result.ok || !result.pendingMerge)
|
|
1034
1147
|
return result;
|
|
1035
1148
|
const action = getHumanAction(actionId);
|
|
@@ -1135,7 +1248,7 @@ const defaultAbortDeps = { killProcess: killRunProcess };
|
|
|
1135
1248
|
* own lease-token-fenced finishWorkItem CAS simply stops matching when it eventually calls
|
|
1136
1249
|
* applyCompletion.
|
|
1137
1250
|
*/
|
|
1138
|
-
export function abortIssue(issueId, resolvedBy, deps = defaultAbortDeps) {
|
|
1251
|
+
export function abortIssue(issueId, resolvedBy, deps = defaultAbortDeps, opts) {
|
|
1139
1252
|
if (!getIssue(issueId))
|
|
1140
1253
|
return { ok: false, code: 404, error: "Issue not found" };
|
|
1141
1254
|
const tx = getDb().transaction(() => {
|
|
@@ -1165,19 +1278,44 @@ export function abortIssue(issueId, resolvedBy, deps = defaultAbortDeps) {
|
|
|
1165
1278
|
});
|
|
1166
1279
|
}
|
|
1167
1280
|
}
|
|
1168
|
-
|
|
1281
|
+
// NOT-196: aborting an issue whose PR already merged outside Dealer lands it as
|
|
1282
|
+
// done so dependents are released. Only a confirmed merge upgrades — an unreadable
|
|
1283
|
+
// state ("unknown") or no PR keeps today's `closed`, with the unknown case noted
|
|
1284
|
+
// in the event. The PR number gates the upgrade, as in the resolve path, and the
|
|
1285
|
+
// status-machine gate keeps it to statuses with a →done edge (abort, unlike the
|
|
1286
|
+
// resolve path, can fire from any status — e.g. developing — where no such edge
|
|
1287
|
+
// exists; there the abort stays closed but the event still names the merged PR so
|
|
1288
|
+
// the operator can see why, and a follow-up could add the edge).
|
|
1289
|
+
const prMerged = opts?.externalMergeState === "merged" && issue.prNumber != null;
|
|
1290
|
+
const externallyMerged = prMerged && canTransitionIssue(issue.status, "done");
|
|
1291
|
+
const finalStatus = externallyMerged ? "done" : "closed";
|
|
1292
|
+
transitionIssue(issueId, finalStatus, {
|
|
1293
|
+
currentOwner: "system",
|
|
1294
|
+
currentIntent: externallyMerged ? "Merged outside Dealer" : "Aborted by operator",
|
|
1295
|
+
});
|
|
1169
1296
|
if (instance)
|
|
1170
|
-
completeWorkflowInstance(instance.id, "closed");
|
|
1297
|
+
completeWorkflowInstance(instance.id, externallyMerged ? "done" : "closed");
|
|
1171
1298
|
appendWorkflowEvent({
|
|
1172
1299
|
issueId,
|
|
1173
1300
|
workflowInstanceId: instance?.id ?? null,
|
|
1174
|
-
type: "issue.closed",
|
|
1301
|
+
type: externallyMerged ? "issue.completed" : "issue.closed",
|
|
1175
1302
|
actorType: "human",
|
|
1176
1303
|
actorRef: resolvedBy,
|
|
1177
|
-
stage:
|
|
1178
|
-
payload:
|
|
1304
|
+
stage: finalStatus,
|
|
1305
|
+
payload: externallyMerged
|
|
1306
|
+
? { reason: "aborted_by_user", externalMerge: true, prNumber: issue.prNumber, prState: "MERGED" }
|
|
1307
|
+
: prMerged
|
|
1308
|
+
? {
|
|
1309
|
+
reason: "aborted_by_user",
|
|
1310
|
+
prNumber: issue.prNumber,
|
|
1311
|
+
prState: "MERGED",
|
|
1312
|
+
doneTransitionBlocked: `no ${issue.status} → done edge`,
|
|
1313
|
+
}
|
|
1314
|
+
: opts?.externalMergeState === "unknown" && issue.prNumber != null
|
|
1315
|
+
? { reason: "aborted_by_user", prNumber: issue.prNumber, prMergeStateUnknown: true }
|
|
1316
|
+
: { reason: "aborted_by_user" },
|
|
1179
1317
|
});
|
|
1180
|
-
return { alreadyClosed: false, issueStatus:
|
|
1318
|
+
return { alreadyClosed: false, issueStatus: finalStatus, runningSessionIds: running };
|
|
1181
1319
|
})();
|
|
1182
1320
|
// Outside the transaction, per the ticket contract: terminating a child process is not
|
|
1183
1321
|
// a DB write, and must happen only once the abort itself is durably committed.
|
|
@@ -1185,3 +1323,44 @@ export function abortIssue(issueId, resolvedBy, deps = defaultAbortDeps) {
|
|
|
1185
1323
|
deps.killProcess(sessionId);
|
|
1186
1324
|
return { ok: true, issueStatus: tx.issueStatus, alreadyClosed: tx.alreadyClosed };
|
|
1187
1325
|
}
|
|
1326
|
+
/**
|
|
1327
|
+
* NOT-196 HTTP/CLI entry for abort: same as `abortIssue`, but pre-reads the PR merge
|
|
1328
|
+
* state outside the transaction first so an externally-merged PR lands the issue as
|
|
1329
|
+
* `done`. Issues without a PR number skip the `gh` call entirely.
|
|
1330
|
+
*/
|
|
1331
|
+
export async function abortIssueAsync(issueId, resolvedBy, deps = defaultAbortDeps) {
|
|
1332
|
+
let externalMergeState;
|
|
1333
|
+
try {
|
|
1334
|
+
const issue = getIssue(issueId);
|
|
1335
|
+
if (issue && issue.prNumber != null) {
|
|
1336
|
+
externalMergeState = await externalMergeStateForIssue(issue);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
catch {
|
|
1340
|
+
externalMergeState = undefined;
|
|
1341
|
+
}
|
|
1342
|
+
return abortIssue(issueId, resolvedBy, deps, { externalMergeState });
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* NOT-196 pre-read for the async close entry: returns a PR merge state only when this
|
|
1346
|
+
* resolution is a `close` choice on an issue that actually has a PR number — otherwise
|
|
1347
|
+
* undefined, which keeps the sync core on today's behavior with no `gh` call. Never
|
|
1348
|
+
* throws: lookup failures mean "nothing to check".
|
|
1349
|
+
*/
|
|
1350
|
+
async function preReadExternalMergeState(actionId, choice) {
|
|
1351
|
+
try {
|
|
1352
|
+
const action = getHumanAction(actionId);
|
|
1353
|
+
if (!action || action.status !== "open" || !action.issueId)
|
|
1354
|
+
return undefined;
|
|
1355
|
+
const resolution = parseHumanResolution(action.actionType, choice);
|
|
1356
|
+
if (!resolution || resolution.choice !== "close")
|
|
1357
|
+
return undefined;
|
|
1358
|
+
const issue = getIssue(action.issueId);
|
|
1359
|
+
if (!issue || issue.prNumber == null)
|
|
1360
|
+
return undefined;
|
|
1361
|
+
return await externalMergeStateForIssue(issue);
|
|
1362
|
+
}
|
|
1363
|
+
catch {
|
|
1364
|
+
return undefined;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
@@ -21,7 +21,8 @@ import { getTaskSnapshot } from "./commands.js";
|
|
|
21
21
|
import { buildDeveloperPrompt } from "./prompts.js";
|
|
22
22
|
import { guidanceForNextSession } from "./guidance.js";
|
|
23
23
|
import { realDeveloperSpawn, developerSessionLogPath } from "./spawn.js";
|
|
24
|
-
import { resolveDeveloperWorktree, safeRemoveWorktree, isWorktreeClean, commitsAhead, pushBranch, mergeBase, branchExists, pushBranchRef, revParseHead, revParseRef, fetchRef, salvageDirtyWorktree, dirtyWorktreeRecoveryCommands, withRepoLock, } from "../adapters/git-worktree.js";
|
|
24
|
+
import { DEFAULT_BASE_FETCH_TIMEOUT_MS, resolveDeveloperWorktree, safeRemoveWorktree, isWorktreeClean, commitsAhead, pushBranch, mergeBase, branchExists, pushBranchRef, revParseHead, revParseRef, fetchRef, salvageDirtyWorktree, dirtyWorktreeRecoveryCommands, withRepoLock, } from "../adapters/git-worktree.js";
|
|
25
|
+
import { recordIssueBaseSha } from "../repository/issues.js";
|
|
25
26
|
import { ensureIssueRepoCheckout, roleWorktreePathForResolution, resolveCheckoutBaseBranch, } from "../adapters/managed-repo.js";
|
|
26
27
|
import { baseRefCandidates, inspectBranchProgress } from "./branch-progress.js";
|
|
27
28
|
import { prepareWorkerDeckConnection, releaseWorkerDeckConnection } from "../adapters/agent-deck-bind.js";
|
|
@@ -93,6 +94,10 @@ export const developerEffectConfig = {
|
|
|
93
94
|
get checksPollIntervalMs() {
|
|
94
95
|
return num("CHECKS_POLL_INTERVAL_MS", 15_000);
|
|
95
96
|
},
|
|
97
|
+
/** NOT-197: bound for the pre-branch `git fetch origin <base>` on a fresh issue branch. */
|
|
98
|
+
get baseFetchTimeoutMs() {
|
|
99
|
+
return num("BASE_FETCH_TIMEOUT_MS", DEFAULT_BASE_FETCH_TIMEOUT_MS);
|
|
100
|
+
},
|
|
96
101
|
/** NOT-110: bounded window to let a lagging `gh pr view` catch up to a just-pushed HEAD. */
|
|
97
102
|
get headReconcileTimeoutMs() {
|
|
98
103
|
return num("HEAD_RECONCILE_TIMEOUT_MS", 30_000);
|
|
@@ -528,7 +533,14 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
528
533
|
reuseBranch,
|
|
529
534
|
worktreePath: desiredWorktreePath,
|
|
530
535
|
ownerLiveness: checkDeveloperWorktreeOwnerLiveness,
|
|
536
|
+
fetchTimeoutMs: developerEffectConfig.baseFetchTimeoutMs,
|
|
531
537
|
});
|
|
538
|
+
if (resolved.kind === "base_unavailable") {
|
|
539
|
+
// NOT-197: the pre-branch fetch failed or timed out — defer the start (no branch
|
|
540
|
+
// was created, nothing spawned, no attempt spent) instead of falling back to the
|
|
541
|
+
// stale local base.
|
|
542
|
+
return { kind: "base_fetch_failed", reason: resolved.reason };
|
|
543
|
+
}
|
|
532
544
|
if (resolved.kind === "conflict") {
|
|
533
545
|
return { kind: "worktree_conflict", path: resolved.path, reason: resolved.reason, recoveryCommands: resolved.recoveryCommands };
|
|
534
546
|
}
|
|
@@ -541,6 +553,13 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
541
553
|
};
|
|
542
554
|
}
|
|
543
555
|
worktreePath = resolved.path;
|
|
556
|
+
if (resolved.kind === "created" && resolved.baseSha) {
|
|
557
|
+
// NOT-197: record the true branch point while it is known — the verified handoff
|
|
558
|
+
// re-checks it via merge-base, but crash/timeout progress inspection below already
|
|
559
|
+
// reads issue.baseSha.
|
|
560
|
+
recordIssueBaseSha(issue.id, resolved.baseSha);
|
|
561
|
+
issue.baseSha = resolved.baseSha;
|
|
562
|
+
}
|
|
544
563
|
}
|
|
545
564
|
catch (err) {
|
|
546
565
|
return { kind: "adapter_failure", reason: `worktree setup failed: ${String(err)}` };
|
|
@@ -27,7 +27,7 @@ const { listWorkItemsForIssue, claimWorkItem, bindWorkItemSession, cancelWorkIte
|
|
|
27
27
|
const { listArtifactsForIssue } = await import("../repository/artifacts-for-issue.js");
|
|
28
28
|
const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
|
|
29
29
|
const { listUsageEventsForIssue } = await import("../repository/usage-events.js");
|
|
30
|
-
const { getActiveWorkflowInstance } = await import("../repository/workflow-events.js");
|
|
30
|
+
const { getActiveWorkflowInstance, listWorkflowEventsForIssue } = await import("../repository/workflow-events.js");
|
|
31
31
|
const { startWorkflow, resolveHumanActionAndAdvance } = await import("./commands.js");
|
|
32
32
|
const { registerEffectHandler, resetEffectHandlers } = await import("./effect-registry.js");
|
|
33
33
|
const { runCoordinatorTick, drainCoordinator } = await import("./worker-loop.js");
|
|
@@ -808,6 +808,153 @@ test("baseSha is resolved against the fetched base ref, not a stale local branch
|
|
|
808
808
|
fs.rmSync(isoRemote, { recursive: true, force: true });
|
|
809
809
|
}
|
|
810
810
|
});
|
|
811
|
+
test("NOT-197: with a lagging local main, a new developer branch starts at the origin/main tip", async () => {
|
|
812
|
+
// The NOT-197 incident: the cached clone's local main lagged origin/main, so branches
|
|
813
|
+
// cut from it conflicted with already-merged work. Unlike the test above, the fake
|
|
814
|
+
// agent does NOT merge origin/main itself — the branch must already start there.
|
|
815
|
+
const isoRepo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-deveff-197-repo-"));
|
|
816
|
+
const isoRemote = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-deveff-197-remote-"));
|
|
817
|
+
try {
|
|
818
|
+
git(isoRepo, "init", "-q", "-b", "main");
|
|
819
|
+
git(isoRepo, "config", "user.email", "test@example.com");
|
|
820
|
+
git(isoRepo, "config", "user.name", "Test");
|
|
821
|
+
fs.writeFileSync(path.join(isoRepo, "README.md"), "hello\n");
|
|
822
|
+
git(isoRepo, "add", ".");
|
|
823
|
+
git(isoRepo, "commit", "-q", "-m", "init");
|
|
824
|
+
execFileSync("git", ["init", "-q", "--bare", "-b", "main", isoRemote]);
|
|
825
|
+
git(isoRepo, "remote", "add", "origin", isoRemote);
|
|
826
|
+
git(isoRepo, "push", "-q", "origin", "main");
|
|
827
|
+
const staleLocalMain = git(isoRepo, "rev-parse", "main");
|
|
828
|
+
const other = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-deveff-197-other-"));
|
|
829
|
+
execFileSync("git", ["clone", "-q", isoRemote, other]);
|
|
830
|
+
git(other, "config", "user.email", "test@example.com");
|
|
831
|
+
git(other, "config", "user.name", "Test");
|
|
832
|
+
fs.writeFileSync(path.join(other, "upstream-change.txt"), "z");
|
|
833
|
+
git(other, "add", ".");
|
|
834
|
+
git(other, "commit", "-q", "-m", "upstream change");
|
|
835
|
+
git(other, "push", "-q", "origin", "main");
|
|
836
|
+
const originTip = git(other, "rev-parse", "HEAD");
|
|
837
|
+
fs.rmSync(other, { recursive: true, force: true });
|
|
838
|
+
assert.notEqual(staleLocalMain, originTip, "the local main must lag origin/main for this test to mean anything");
|
|
839
|
+
const dev = createAgent({ name: `dev-197-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
|
|
840
|
+
const rev = createAgent({ name: `rev-197-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
|
|
841
|
+
const issueId = createIssue({
|
|
842
|
+
title: "Fresh base",
|
|
843
|
+
acceptanceCriteria: "works",
|
|
844
|
+
repo: isoRepo,
|
|
845
|
+
baseBranch: "main",
|
|
846
|
+
developerAgentId: dev.id,
|
|
847
|
+
reviewerAgentId: rev.id,
|
|
848
|
+
maxReviewRounds: 3,
|
|
849
|
+
maxInfraAttempts: 3,
|
|
850
|
+
source: "manual"
|
|
851
|
+
}).id;
|
|
852
|
+
const isoPrs = new Map();
|
|
853
|
+
const isoGithub = {
|
|
854
|
+
async viewPr({ branch }) {
|
|
855
|
+
if (!branch)
|
|
856
|
+
throw new Error("isoGithub.viewPr requires an explicit branch — bare current-branch lookup is the NOT-82 bug");
|
|
857
|
+
const pr = isoPrs.get(branch);
|
|
858
|
+
if (!pr)
|
|
859
|
+
return null;
|
|
860
|
+
return { number: pr.number, url: pr.url, baseRefName: pr.base, headRefName: branch, headRefOid: git(isoRemote, "rev-parse", branch), isDraft: true };
|
|
861
|
+
},
|
|
862
|
+
async createDraftPr({ base, head }) {
|
|
863
|
+
if (!head)
|
|
864
|
+
throw new Error("isoGithub.createDraftPr requires an explicit --head — bare create is the NOT-82 bug");
|
|
865
|
+
isoPrs.set(head, { number: 1, url: "https://github.com/o/r/pull/1", base });
|
|
866
|
+
return { ok: true, number: 1, url: "https://github.com/o/r/pull/1" };
|
|
867
|
+
},
|
|
868
|
+
async checksSnapshot() {
|
|
869
|
+
return "success";
|
|
870
|
+
},
|
|
871
|
+
async publishReview() {
|
|
872
|
+
throw new Error("publishReview is unused by the developer effect");
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: isoGithub }));
|
|
876
|
+
startWorkflow(issueId);
|
|
877
|
+
await pump(1);
|
|
878
|
+
const issue = getIssue(issueId);
|
|
879
|
+
assert.equal(issue.status, "reviewing");
|
|
880
|
+
const branch = `issue-${issueId}`;
|
|
881
|
+
assert.equal(issue.branch, branch);
|
|
882
|
+
assert.equal(git(isoRepo, "rev-parse", `${branch}^`), originTip, "the new branch must be cut from the origin/main tip, not the stale local main");
|
|
883
|
+
assert.equal(issue.baseSha, originTip, "base_sha must equal the fetched origin/main tip");
|
|
884
|
+
assert.equal(git(isoRepo, "rev-parse", "main"), staleLocalMain, "sanity: the local main is still stale — the fix fetches, it does not move local branches");
|
|
885
|
+
}
|
|
886
|
+
finally {
|
|
887
|
+
fs.rmSync(isoRepo, { recursive: true, force: true });
|
|
888
|
+
fs.rmSync(isoRemote, { recursive: true, force: true });
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
test("NOT-197: a failed pre-branch fetch defers the start without a branch, a spawn, or an attempt", async () => {
|
|
892
|
+
// origin points at nothing fetchable: the effect must defer (worker.deferred, item
|
|
893
|
+
// pending, budgets untouched) instead of cutting the branch from the stale local base.
|
|
894
|
+
const isoRepo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-deveff-197fail-repo-"));
|
|
895
|
+
const isoRemote = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-deveff-197fail-remote-"));
|
|
896
|
+
try {
|
|
897
|
+
git(isoRepo, "init", "-q", "-b", "main");
|
|
898
|
+
git(isoRepo, "config", "user.email", "test@example.com");
|
|
899
|
+
git(isoRepo, "config", "user.name", "Test");
|
|
900
|
+
fs.writeFileSync(path.join(isoRepo, "README.md"), "hello\n");
|
|
901
|
+
git(isoRepo, "add", ".");
|
|
902
|
+
git(isoRepo, "commit", "-q", "-m", "init");
|
|
903
|
+
execFileSync("git", ["init", "-q", "--bare", "-b", "main", isoRemote]);
|
|
904
|
+
git(isoRepo, "remote", "add", "origin", isoRemote);
|
|
905
|
+
git(isoRepo, "push", "-q", "origin", "main");
|
|
906
|
+
git(isoRepo, "remote", "set-url", "origin", path.join(isoRemote, "does-not-exist.git"));
|
|
907
|
+
const dev = createAgent({ name: `dev-197f-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
|
|
908
|
+
const rev = createAgent({ name: `rev-197f-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
|
|
909
|
+
const issueId = createIssue({
|
|
910
|
+
title: "Fetch fails",
|
|
911
|
+
acceptanceCriteria: "works",
|
|
912
|
+
repo: isoRepo,
|
|
913
|
+
baseBranch: "main",
|
|
914
|
+
developerAgentId: dev.id,
|
|
915
|
+
reviewerAgentId: rev.id,
|
|
916
|
+
maxReviewRounds: 3,
|
|
917
|
+
maxInfraAttempts: 3,
|
|
918
|
+
source: "manual"
|
|
919
|
+
}).id;
|
|
920
|
+
let spawnCalled = false;
|
|
921
|
+
const spySpawn = async () => {
|
|
922
|
+
spawnCalled = true;
|
|
923
|
+
return { exitCode: 0, transcript: "", logPath: "/dev/null", timedOut: false };
|
|
924
|
+
};
|
|
925
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: spySpawn, github: fakeGithub() }));
|
|
926
|
+
startWorkflow(issueId);
|
|
927
|
+
await pump(1);
|
|
928
|
+
assert.equal(spawnCalled, false, "nothing may spawn when the base could not be fetched");
|
|
929
|
+
assert.equal(await branchExists(isoRepo, `issue-${issueId}`), false, "no branch may be created from a base that was never confirmed");
|
|
930
|
+
const issue = getIssue(issueId);
|
|
931
|
+
assert.equal(issue.branch, null);
|
|
932
|
+
assert.equal(issue.baseSha, null);
|
|
933
|
+
assert.equal(issue.infraAttempts, 0, "a deferred start spends no infra attempt");
|
|
934
|
+
assert.equal(issue.currentRound, 1);
|
|
935
|
+
assert.notEqual(issue.status, "needs_human");
|
|
936
|
+
assert.match(issue.currentIntent ?? "", /Waiting for network/);
|
|
937
|
+
const { listWorkItemsForIssue } = await import("../repository/work-items.js");
|
|
938
|
+
const items = listWorkItemsForIssue(issueId);
|
|
939
|
+
assert.equal(items.length, 1);
|
|
940
|
+
assert.equal(items[0].status, "pending", "the item waits, it is not finished or dead-lettered");
|
|
941
|
+
assert.equal(items[0].attemptCount, 0, "the claim-time attempt bump is reverted");
|
|
942
|
+
assert.ok(Date.parse(items[0].availableAt) > Date.parse(items[0].updatedAt), "the next start is gated behind a wait");
|
|
943
|
+
assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0);
|
|
944
|
+
const events = listWorkflowEventsForIssue(issueId);
|
|
945
|
+
const deferrals = events.filter((e) => e.type === "worker.deferred");
|
|
946
|
+
assert.ok(deferrals.length > 0);
|
|
947
|
+
const payload = JSON.parse(deferrals[0].payloadJson ?? "{}");
|
|
948
|
+
assert.equal(payload.outcome, "base_fetch_failed");
|
|
949
|
+
assert.equal(events.some((e) => e.type === "worker.failed"), false, "a deferred start is not a failure");
|
|
950
|
+
const { listWorkerSessionsForIssue } = await import("../repository/worker-sessions.js");
|
|
951
|
+
assert.equal(listWorkerSessionsForIssue(issueId).every((s) => s.status === "cancelled"), true);
|
|
952
|
+
}
|
|
953
|
+
finally {
|
|
954
|
+
fs.rmSync(isoRepo, { recursive: true, force: true });
|
|
955
|
+
fs.rmSync(isoRemote, { recursive: true, force: true });
|
|
956
|
+
}
|
|
957
|
+
});
|
|
811
958
|
test("NOT-83 review: an item cancelled during worktree/deck-bind setup (before spawn) is never spawned", async () => {
|
|
812
959
|
const { buildProfileSnapshot, serializeProfileSnapshot } = await import("./profile-snapshot.js");
|
|
813
960
|
const issueId = await makeIssue();
|