@wascartech/devbot 2.2.0 → 2.3.0

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/runner.js CHANGED
@@ -14,7 +14,7 @@ import { buildPrompt, formatHistory } from "./prompt.js";
14
14
  import { BUILTIN_SUBAGENTS, validateAgentNames } from "./mention.js";
15
15
  import { renderBuildWorkflow } from "./build-workflow.js";
16
16
  import { pathPolicyEnvArgs } from "./path-sandbox.js";
17
- import { formatConflictFixNote, formatUnresolvedNote, formatFixOnlyUnresolvedNote, formatNoConflictsNote } from "./conflict-fix.js";
17
+ import { formatConflictFixNote, formatUnresolvedNote, formatFixOnlyUnresolvedNote, formatNoConflictsNote, formatAdoptedPushRefusedNote, formatAdoptedForceRefusedNote, formatAdoptedConflictRefusedNote } from "./conflict-fix.js";
18
18
  import { writeRunFilesToVolume, ensurePodmanStore } from "./run-files.js";
19
19
  import { startStreamedPhase, endStreamedPhase, appendChunk } from "./run-log.js";
20
20
  import { addUsage } from "./run-usage.js";
@@ -22,10 +22,11 @@ import { parseMrContent } from "./mr-content.js";
22
22
  import { buildCiFailureSection } from "./ci-failure.js";
23
23
  import { recordChangeRequest } from "./change-request-store.js";
24
24
  import { createChangeRequestWithAssignee } from "./mr-assignee.js";
25
+ import { runCiLintGuard } from "./ci-lint-guard.js";
25
26
  import { decideChangeRequestAction, openChangeRequest } from "./change-request-lifecycle.js";
26
27
  import { recordVolume, touchVolume } from "./volume-store.js";
27
28
  import { setBaseBranchIfUnset, getBaseBranch } from "./base-branch-store.js";
28
- import { setWorkBranchIfUnset, getWorkBranch } from "./work-item-branch-store.js";
29
+ import { setWorkBranchIfUnset, getWorkBranch, isWorkBranchAdopted } from "./work-item-branch-store.js";
29
30
  import { isWorkItemCancelRequested } from "./queue-store.js";
30
31
  import { emitMrEvent } from "./outbound-webhook.js";
31
32
  import { resolveStorageDriver, nestedPodmanArgs, devcontainerVolumeArgs, devcontainerEnvArgs, devcontainerPromptSection, devcontainerEnabled, hasFuseDevice, sandboxBaseArgs, } from "./devcontainer.js";
@@ -75,7 +76,7 @@ export function modelArgs(model, variant = "") {
75
76
  // opencode.json persist across mentions yet can never be staged by the
76
77
  // agent's `git add -A`. State + cache go to /tmp: when dev containers are
77
78
  // enabled the nested-Podman store is mounted at
78
- // /home/devbot/.local/share/containers under --userns=keep-id, which makes
79
+ // /home/devbot/.local/share/containers under the pinned keep-id mapping, which makes
79
80
  // podman create the missing parents /home/devbot/.local{,/share} as root:root
80
81
  // 1755 — so opencode's Bun default (~/.local/state) cannot be created
81
82
  // (EACCES). /tmp is always writable and off every volume, so lock/state files
@@ -96,6 +97,22 @@ export function xdgEnvArgs() {
96
97
  function failure(phase, kind, message, logs = "", exitCode = null, model = "", variant = "") {
97
98
  return { phase, kind, exitCode, message, logs: redact(logs), model, variant };
98
99
  }
100
+ // Note for a push that failed because a repository object database refused the
101
+ // write. A lost push is an error, never a warning. The location distinguishes
102
+ // the two object databases involved: `local` is the workspace volume the push
103
+ // container fetches into, `server` is the forge repository receiving the push.
104
+ // A server-side failure is a forge storage problem (object ownership,
105
+ // permissions or disk), not something DevBot may paper over.
106
+ export function lostPushNote(location, reason) {
107
+ const cause = location === "local"
108
+ ? "DevBot's workspace repository (`/workspace/.git/objects`) refused the object write (local permissions or disk)."
109
+ : location === "server"
110
+ ? "The git server's repository object database refused the write — a server-side storage problem (object ownership/permissions or disk), not a DevBot bug."
111
+ : "A repository object database refused the object write.";
112
+ return (`❌ push failed — the branch was NOT pushed.\n\n${cause}\n\n` +
113
+ "Fix the repository storage before re-running; the commit is preserved in the workspace volume.\n\n" +
114
+ "```\n" + reason + "\n```");
115
+ }
99
116
  // Post a non-fatal note when some submodules could not be fetched. The run
100
117
  // proceeds without them (best-effort by design); this just surfaces which
101
118
  // paths are missing so the user/admin can fix access. Never throws. Exported
@@ -273,6 +290,11 @@ export async function runForcePushOnly(input) {
273
290
  workItemNumber: input.workItemNumber,
274
291
  workItemId: input.workItemId,
275
292
  });
293
+ if (isWorkBranchAdopted(input.workItemId)) {
294
+ await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatAdoptedForceRefusedNote(branch))
295
+ .catch((e) => log.warn("adopted-force-refused note failed", e));
296
+ return { kind: "terminal", reason: "force-push refused on adopted branch" };
297
+ }
276
298
  if (config.DRY_RUN) {
277
299
  log.info("DRY_RUN — would force-push branch", { branch });
278
300
  await getPlatform().workItems
@@ -280,20 +302,28 @@ export async function runForcePushOnly(input) {
280
302
  .catch((e) => log.warn("dry-run force-push note failed", e));
281
303
  return { kind: "ok" };
282
304
  }
283
- const pushed = await forcePushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch }, projectEnvVars(input.projectId), projectBuilderImage(input.projectId));
305
+ const envVars = projectEnvVars(input.projectId);
306
+ const builderImage = projectBuilderImage(input.projectId);
307
+ const pushed = await forcePushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch }, envVars, builderImage, ciLintGate(input, envVars, builderImage));
284
308
  if (!pushed.ok) {
309
+ if (pushed.gateBlocked) {
310
+ return { kind: "terminal", reason: "ci/lint rejected the CI configuration", failure: failure("push", "ci-lint-invalid", pushed.reason, pushed.logs) };
311
+ }
285
312
  log.error("standalone force-push failed", { workItemId: input.workItemId, reason: pushed.reason });
286
313
  const reply = pushed.missingVolume
287
314
  ? `⚠️ Nothing to force-push — no workspace exists for this work item yet. Run a \`/build\` first, then \`/force-push\`.`
288
315
  : pushed.missingBranch
289
316
  ? `⚠️ Nothing to force-push — no local branch \`${branch}\` for this work item.`
290
- : `⚠️ force-push failed: ${pushed.reason}`;
317
+ : pushed.objectDbFailure
318
+ ? lostPushNote(pushed.objectDbLocation, pushed.reason)
319
+ : `⚠️ force-push failed: ${pushed.reason}`;
291
320
  await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, reply).catch((e) => log.warn("force-push-failure note failed", e));
292
- // Missing workspace/branch are terminal (a re-run hits the same state);
293
- // a push failure may be a network blip or a lease rejection the retry's
294
- // fresh fetch resolves — transient.
321
+ // Missing workspace/branch are terminal (a re-run hits the same state); a
322
+ // repository object-database write failure is also terminal (retrying the
323
+ // same unwritable store cannot help and a lost push must be an error). Any
324
+ // other push failure may be a network blip — transient.
295
325
  const failureDetail = failure("push", pushed.missingVolume || pushed.missingBranch ? "force-push-no-target" : "force-push-failed", pushed.reason, pushed.logs);
296
- return pushed.missingVolume || pushed.missingBranch
326
+ return pushed.missingVolume || pushed.missingBranch || pushed.objectDbFailure
297
327
  ? { kind: "terminal", reason: pushed.reason, failure: failureDetail }
298
328
  : { kind: "transient", reason: pushed.reason, failure: failureDetail };
299
329
  }
@@ -309,6 +339,7 @@ export async function runForcePushOnly(input) {
309
339
  // never race an in-flight build's push. Same error-log/retry machinery.
310
340
  export async function runPushOnly(input) {
311
341
  const branch = resolveWorkBranch(getWorkBranch(input.workItemId), input.workItemNumber);
342
+ const adopted = isWorkBranchAdopted(input.workItemId);
312
343
  log.info("push (standalone) start", {
313
344
  projectId: input.projectId,
314
345
  workItemNumber: input.workItemNumber,
@@ -321,19 +352,30 @@ export async function runPushOnly(input) {
321
352
  .catch((e) => log.warn("dry-run push note failed", e));
322
353
  return { kind: "ok" };
323
354
  }
324
- const pushed = await pushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch }, projectEnvVars(input.projectId), projectBuilderImage(input.projectId));
355
+ const envVars = projectEnvVars(input.projectId);
356
+ const builderImage = projectBuilderImage(input.projectId);
357
+ const pushed = await pushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch, workBranchAdopted: adopted }, envVars, builderImage, ciLintGate(input, envVars, builderImage));
325
358
  if (!pushed.ok) {
359
+ if (pushed.gateBlocked) {
360
+ return { kind: "terminal", reason: "ci/lint rejected the CI configuration", failure: failure("push", "ci-lint-invalid", pushed.reason, pushed.logs) };
361
+ }
326
362
  log.error("standalone push failed", { workItemId: input.workItemId, reason: pushed.reason });
327
363
  const reply = pushed.missingVolume
328
364
  ? `⚠️ Nothing to push — no workspace exists for this work item yet. Run a \`/build\` first, then \`/push\`.`
329
365
  : pushed.missingBranch
330
366
  ? `⚠️ Nothing to push — no local branch \`${branch}\` for this work item.`
331
- : `⚠️ push failed: ${pushed.reason}`;
367
+ : pushed.objectDbFailure
368
+ ? lostPushNote(pushed.objectDbLocation, pushed.reason)
369
+ : pushed.adoptedDiverged
370
+ ? formatAdoptedPushRefusedNote(branch)
371
+ : `⚠️ push failed: ${pushed.reason}`;
332
372
  await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, reply).catch((e) => log.warn("push-failure note failed", e));
333
373
  // Missing workspace/branch are terminal (a re-run hits the same state); a
334
- // push failure may be a network blip — transient.
374
+ // repository object-database write failure is also terminal (retrying the
375
+ // same unwritable store cannot help and a lost push must be an error). Any
376
+ // other push failure may be a network blip — transient.
335
377
  const failureDetail = failure("push", pushed.missingVolume || pushed.missingBranch ? "push-no-target" : "push-failed", pushed.reason, pushed.logs);
336
- return pushed.missingVolume || pushed.missingBranch
378
+ return pushed.missingVolume || pushed.missingBranch || pushed.objectDbFailure
337
379
  ? { kind: "terminal", reason: pushed.reason, failure: failureDetail }
338
380
  : { kind: "transient", reason: pushed.reason, failure: failureDetail };
339
381
  }
@@ -350,12 +392,18 @@ export async function runPushOnly(input) {
350
392
  // branch skips straight to the MR step.
351
393
  export async function runMrOnly(input) {
352
394
  const branch = resolveWorkBranch(getWorkBranch(input.workItemId), input.workItemNumber);
395
+ const adopted = isWorkBranchAdopted(input.workItemId);
353
396
  log.info("mr (standalone) start", {
354
397
  projectId: input.projectId,
355
398
  workItemNumber: input.workItemNumber,
356
399
  workItemId: input.workItemId,
357
400
  forcePush: input.forcePush === true,
358
401
  });
402
+ if (adopted && input.forcePush) {
403
+ await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatAdoptedForceRefusedNote(branch))
404
+ .catch((e) => log.warn("adopted-force-refused note failed", e));
405
+ return { kind: "terminal", reason: "force-push refused on adopted branch" };
406
+ }
359
407
  if (config.DRY_RUN) {
360
408
  log.info("DRY_RUN — would push + open MR", { branch });
361
409
  await getPlatform().workItems
@@ -363,20 +411,30 @@ export async function runMrOnly(input) {
363
411
  .catch((e) => log.warn("dry-run mr note failed", e));
364
412
  return { kind: "ok" };
365
413
  }
414
+ const envVars = projectEnvVars(input.projectId);
415
+ const builderImage = projectBuilderImage(input.projectId);
416
+ const gate = ciLintGate(input, envVars, builderImage);
366
417
  // Publish first. A missing workspace/branch is terminal and actionable.
367
418
  const pushed = input.forcePush
368
- ? await forcePushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch }, projectEnvVars(input.projectId), projectBuilderImage(input.projectId))
369
- : await pushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch }, projectEnvVars(input.projectId), projectBuilderImage(input.projectId));
419
+ ? await forcePushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch }, envVars, builderImage, gate)
420
+ : await pushStandalone({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch, workBranchAdopted: adopted }, envVars, builderImage, gate);
370
421
  if (!pushed.ok) {
422
+ if (pushed.gateBlocked) {
423
+ return { kind: "terminal", reason: "ci/lint rejected the CI configuration", failure: failure("push", "ci-lint-invalid", pushed.reason, pushed.logs) };
424
+ }
371
425
  log.error("standalone /mr push failed", { workItemId: input.workItemId, reason: pushed.reason });
372
426
  const reply = pushed.missingVolume
373
427
  ? `⚠️ Nothing to publish — no workspace exists for this work item yet. Run a \`/build\` first, then \`/mr\`.`
374
428
  : pushed.missingBranch
375
429
  ? `⚠️ Nothing to publish — no local branch \`${branch}\` for this work item.`
376
- : `⚠️ push failed: ${pushed.reason}`;
430
+ : pushed.objectDbFailure
431
+ ? lostPushNote(pushed.objectDbLocation, pushed.reason)
432
+ : "adoptedDiverged" in pushed && pushed.adoptedDiverged
433
+ ? formatAdoptedPushRefusedNote(branch)
434
+ : `⚠️ push failed: ${pushed.reason}`;
377
435
  await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, reply).catch((e) => log.warn("mr-push-failure note failed", e));
378
436
  const failureDetail = failure("push", pushed.missingVolume || pushed.missingBranch ? "mr-no-target" : "mr-push-failed", pushed.reason, pushed.logs);
379
- return pushed.missingVolume || pushed.missingBranch
437
+ return pushed.missingVolume || pushed.missingBranch || pushed.objectDbFailure
380
438
  ? { kind: "terminal", reason: pushed.reason, failure: failureDetail }
381
439
  : { kind: "transient", reason: pushed.reason, failure: failureDetail };
382
440
  }
@@ -389,7 +447,11 @@ export async function runMrOnly(input) {
389
447
  // guarantee as the post-build path. Conflicts are checked here; only when
390
448
  // the check actually runs do we mint an MCP context (a fresh bearer token +
391
449
  // current providers) so the resolver build run has working forge tools.
392
- if (projectAutoResolveConflicts(input.projectId, input.workItemId)) {
450
+ if (projectAutoResolveConflicts(input.projectId, input.workItemId) && adopted) {
451
+ await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatAdoptedConflictRefusedNote(branch))
452
+ .catch((e) => log.warn("adopted-conflict-refused note failed", e));
453
+ }
454
+ else if (projectAutoResolveConflicts(input.projectId, input.workItemId)) {
393
455
  const envVars = projectEnvVars(input.projectId);
394
456
  const builderImage = projectBuilderImage(input.projectId);
395
457
  const cancelledPreCheck = cancelledOutcome(input.workItemId, "pre-conflict-check");
@@ -427,7 +489,7 @@ export async function runMrOnly(input) {
427
489
  if (rechecked && rechecked.conflictFree) {
428
490
  const repushed = await pushBranch({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch, ...(preFixTip ? { preRunTip: preFixTip } : {}) }, envVars, builderImage);
429
491
  if (!repushed.ok) {
430
- await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatUnresolvedNote(mrResolved.branch, checked.files))
492
+ await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, repushed.objectDbFailure ? lostPushNote(repushed.objectDbLocation, repushed.reason) : formatUnresolvedNote(mrResolved.branch, checked.files))
431
493
  .catch((e) => log.warn("conflict-repush-failure note failed", e));
432
494
  return { kind: "terminal", reason: `conflict-fix re-push failed: ${repushed.reason}`, failure: failure("conflict", "repush-failed", repushed.reason, repushed.logs, null, runModel.model, runModel.variant) };
433
495
  }
@@ -508,6 +570,11 @@ export async function runFixConflictsOnly(input) {
508
570
  workItemNumber: input.workItemNumber,
509
571
  workItemId: input.workItemId,
510
572
  });
573
+ if (isWorkBranchAdopted(input.workItemId)) {
574
+ await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatAdoptedConflictRefusedNote(branch))
575
+ .catch((e) => log.warn("adopted-conflict-refused note failed", e));
576
+ return { kind: "terminal", reason: "conflict resolution refused on adopted branch" };
577
+ }
511
578
  if (config.DRY_RUN) {
512
579
  log.info("DRY_RUN — would fix merge conflicts", { branch });
513
580
  await getPlatform().workItems
@@ -588,9 +655,20 @@ export async function runFixConflictsOnly(input) {
588
655
  ? await checkConflicts({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch, target }, envVars, builderImage)
589
656
  : null;
590
657
  if (rechecked && rechecked.conflictFree) {
658
+ const ciLint = await runCiLintGuard({
659
+ projectId: input.projectId,
660
+ workItemId: input.workItemId,
661
+ workItemNumber: input.workItemNumber,
662
+ baseBranch: target,
663
+ envVars,
664
+ builderImage,
665
+ });
666
+ if (ciLint.status === "invalid") {
667
+ return { kind: "terminal", reason: "ci/lint rejected the CI configuration", failure: failure("conflict", "ci-lint-invalid", ciLint.errors.join("\n") || "invalid CI configuration", "", null, runModel.model, runModel.variant) };
668
+ }
591
669
  const repushed = await pushBranch({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch: branch, ...(preFixTip ? { preRunTip: preFixTip } : {}) }, envVars, builderImage);
592
670
  if (!repushed.ok) {
593
- await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatFixOnlyUnresolvedNote(target, checked.files))
671
+ await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, repushed.objectDbFailure ? lostPushNote(repushed.objectDbLocation, repushed.reason) : formatFixOnlyUnresolvedNote(target, checked.files))
594
672
  .catch((e) => log.warn("fix-conflicts-repush-failure note failed", e));
595
673
  return { kind: "terminal", reason: `conflict-fix re-push failed: ${repushed.reason}`, failure: failure("conflict", "repush-failed", repushed.reason, repushed.logs, null, runModel.model, runModel.variant) };
596
674
  }
@@ -643,6 +721,39 @@ async function resolveMrTarget(projectId, workItemId, branches, model, variant)
643
721
  }
644
722
  return { ok: true, branch: hit };
645
723
  }
724
+ // The base branch used to resolve lint includes. The stored /base value wins;
725
+ // otherwise the configured base-branch list is probed. A lookup failure leaves
726
+ // the guard skipped rather than blocking a publish the run was about to make.
727
+ async function resolveCiLintBaseBranch(projectId, workItemId) {
728
+ const stored = getBaseBranch(workItemId);
729
+ if (stored)
730
+ return stored;
731
+ try {
732
+ return await getPlatform().repository.firstExistingBranch(projectId, projectBaseBranches(projectId));
733
+ }
734
+ catch (e) {
735
+ log.warn("ci/lint base branch lookup failed", { workItemId, err: e });
736
+ return null;
737
+ }
738
+ }
739
+ // The publish gate handed to pushStandalone/forcePushStandalone: it runs the
740
+ // lint after the workspace existence check (so a missing workspace still stops
741
+ // on that inspect) and blocks only on an invalid verdict.
742
+ function ciLintGate(input, envVars, builderImage) {
743
+ return async () => {
744
+ const result = await runCiLintGuard({
745
+ projectId: input.projectId,
746
+ workItemId: input.workItemId,
747
+ workItemNumber: input.workItemNumber,
748
+ baseBranch: await resolveCiLintBaseBranch(input.projectId, input.workItemId),
749
+ envVars,
750
+ builderImage,
751
+ });
752
+ return result.status === "invalid"
753
+ ? { ok: false, reason: result.errors.join("\n") || "invalid CI configuration" }
754
+ : { ok: true };
755
+ };
756
+ }
646
757
  export async function run(input) {
647
758
  log.info("run start", {
648
759
  projectId: input.projectId,
@@ -690,7 +801,7 @@ export async function run(input) {
690
801
  // with a note; the stored value stays authoritative.
691
802
  const storedWorkBranch = getWorkBranch(input.workItemId);
692
803
  if (input.workBranch && !storedWorkBranch) {
693
- setWorkBranchIfUnset(input.workItemId, input.workBranch);
804
+ setWorkBranchIfUnset(input.workItemId, input.workBranch, input.workBranchAdopted === true);
694
805
  }
695
806
  else if (input.workBranch && storedWorkBranch && input.workBranch !== storedWorkBranch) {
696
807
  await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, `⚠️ /branch '${input.workBranch}' ignored — this work item already uses branch '${storedWorkBranch}'.`)
@@ -700,7 +811,8 @@ export async function run(input) {
700
811
  // (storedWorkBranch is null on that run, so resolving it directly would
701
812
  // wrongly fall back to wi-<number>).
702
813
  const workBranch = resolveWorkBranch(getWorkBranch(input.workItemId), input.workItemNumber);
703
- const prepared = await ensureVolumeAndWorkspace({ ...input, workBranch }, input.mode, mcpCtx.envVars, mcpCtx.baseBranches, mcpCtx.builderImage);
814
+ const workBranchAdopted = isWorkBranchAdopted(input.workItemId);
815
+ const prepared = await ensureVolumeAndWorkspace({ ...input, workBranch, workBranchAdopted }, input.mode, mcpCtx.envVars, mcpCtx.baseBranches, mcpCtx.builderImage);
704
816
  // Cancellation checkpoint: a stop during clone/sync kills that container;
705
817
  // treat the resulting prepare failure as an intentional stop, not an error.
706
818
  const cancelledAfterPrep = cancelledOutcome(input.workItemId, "post-prepare");
@@ -940,7 +1052,25 @@ export async function run(input) {
940
1052
  const cancelledBeforePush = cancelledOutcome(input.workItemId, "pre-push");
941
1053
  if (cancelledBeforePush)
942
1054
  return cancelledBeforePush;
943
- const pushed = await pushBranch({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch, ...(input.forcePush ? { forcePush: true } : {}), ...(prepared.branchTip ? { preRunTip: prepared.branchTip } : {}) }, mcpCtx.envVars, mcpCtx.builderImage);
1055
+ // CI guard (#116): the last commit is in the working tree now, so lint the
1056
+ // entry file once before the branch becomes visible. Invalid config never
1057
+ // reaches the forge; an unavailable linter only adds a report line.
1058
+ const ciLint = await runCiLintGuard({
1059
+ projectId: input.projectId,
1060
+ workItemId: input.workItemId,
1061
+ workItemNumber: input.workItemNumber,
1062
+ baseBranch: mrTarget,
1063
+ envVars: mcpCtx.envVars,
1064
+ builderImage: mcpCtx.builderImage,
1065
+ });
1066
+ if (ciLint.status === "invalid") {
1067
+ return {
1068
+ kind: "terminal",
1069
+ reason: "ci/lint rejected the CI configuration",
1070
+ failure: failure("push", "ci-lint-invalid", ciLint.errors.join("\n") || "invalid CI configuration", "", null, runModel.model, runModel.variant),
1071
+ };
1072
+ }
1073
+ const pushed = await pushBranch({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch, workBranchAdopted, ...(input.forcePush ? { forcePush: true } : {}), ...(prepared.branchTip ? { preRunTip: prepared.branchTip } : {}) }, mcpCtx.envVars, mcpCtx.builderImage);
944
1074
  if (pushed.ok && pushed.fallbackCommitted) {
945
1075
  log.warn("agent forgot to commit; devbot authored fallback commit", {
946
1076
  workItemId: input.workItemId,
@@ -948,8 +1078,13 @@ export async function run(input) {
948
1078
  });
949
1079
  }
950
1080
  if (!pushed.ok) {
951
- log.error("push failed", { workItemId: input.workItemId, stderr: pushed.reason });
952
- await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, `⚠️ push failed: ${pushed.reason}`)
1081
+ log.error("push failed", { workItemId: input.workItemId, stderr: pushed.reason, objectDbFailure: pushed.objectDbFailure, objectDbLocation: pushed.objectDbLocation });
1082
+ const pushNote = pushed.objectDbFailure
1083
+ ? lostPushNote(pushed.objectDbLocation, pushed.reason)
1084
+ : pushed.adoptedDiverged
1085
+ ? formatAdoptedPushRefusedNote(workBranch)
1086
+ : `❌ push failed — the branch was NOT pushed: ${pushed.reason}`;
1087
+ await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, pushNote)
953
1088
  .catch((e) => log.warn("push-failure note failed", e));
954
1089
  return { kind: "terminal", reason: `push failed: ${pushed.reason}`, failure: failure("push", "push-failed", pushed.reason, pushed.logs, null, runModel.model, runModel.variant) };
955
1090
  }
@@ -972,7 +1107,11 @@ export async function run(input) {
972
1107
  // found, a direct build run resolves them, then the branch is re-pushed
973
1108
  // (rewrite-aware lease) before the MR. On failure the branch stays
974
1109
  // published, the MR is skipped, and an actionable note is posted.
975
- if (shouldFixConflicts(input, mcpCtx.autoResolveConflicts)) {
1110
+ if (shouldFixConflicts(input, mcpCtx.autoResolveConflicts) && workBranchAdopted) {
1111
+ await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatAdoptedConflictRefusedNote(branch))
1112
+ .catch((e) => log.warn("adopted-conflict-refused note failed", e));
1113
+ }
1114
+ else if (shouldFixConflicts(input, mcpCtx.autoResolveConflicts)) {
976
1115
  const cancelledPreCheck = cancelledOutcome(input.workItemId, "pre-conflict-check");
977
1116
  if (cancelledPreCheck)
978
1117
  return cancelledPreCheck;
@@ -1003,7 +1142,7 @@ export async function run(input) {
1003
1142
  const repushed = await pushBranch({ workItemId: input.workItemId, workItemNumber: input.workItemNumber, workBranch, ...(preFixTip ? { preRunTip: preFixTip } : {}) }, mcpCtx.envVars, mcpCtx.builderImage);
1004
1143
  if (!repushed.ok) {
1005
1144
  log.error("conflict-fix re-push failed", { workItemId: input.workItemId, reason: repushed.reason });
1006
- await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, formatUnresolvedNote(mrTarget, checked.files))
1145
+ await getPlatform().workItems.postComment(input.projectId, input.workItemNumber, repushed.objectDbFailure ? lostPushNote(repushed.objectDbLocation, repushed.reason) : formatUnresolvedNote(mrTarget, checked.files))
1007
1146
  .catch((e) => log.warn("conflict-repush-failure note failed", e));
1008
1147
  return { kind: "terminal", reason: `conflict-fix re-push failed: ${repushed.reason}`, failure: failure("conflict", "repush-failed", repushed.reason, repushed.logs, null, runModel.model, runModel.variant) };
1009
1148
  }