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.
@@ -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
- function questionFor(actionType, reason, resumeAsReviewer = false) {
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
- (resolution.choice === "merge" || resolution.choice === "complete")) {
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: "final_review", choice: resolution.choice, pendingMerge: true },
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 (resumeStatus), not the
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 ev = eventEmitter(issue, instance, null, resumeStatus, issue.currentRound);
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, resumeStatus, {
944
- currentOwner: resumeStatus === "done" || resumeStatus === "closed" ? "system" : resumeAsReviewer ? "reviewer" : "developer",
945
- currentIntent: resumeStatus === "done"
946
- ? "Complete"
947
- : resumeStatus === "closed"
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
- completeWorkflowInstance(instance.id, outcome.workflowOutcome);
955
- ev.emit(outcome.workflowOutcome === "done" ? "issue.completed" : "issue.closed");
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
- const result = resolveHumanActionAndAdvance(actionId, resolvedBy, choice);
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
- transitionIssue(issueId, "closed", { currentOwner: "system", currentIntent: "Aborted by operator" });
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: "closed",
1178
- payload: { reason: "aborted_by_user" },
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: "closed", runningSessionIds: running };
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();