@cat-factory/executor-harness 1.31.6 → 1.31.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent.js CHANGED
@@ -533,6 +533,24 @@ async function runCodingMode(job, opts) {
533
533
  ...(job.repo.provider ? { provider: job.repo.provider } : {}),
534
534
  signal: opts.signal,
535
535
  });
536
+ // `null` ⇒ the branch has nothing ahead of base, so there was no PR to open (a resumed
537
+ // branch whose earlier PR already merged). Record it as a clean no-op rather than a push,
538
+ // mirroring the no-changes outcome — the `runCodingAgent` guard normally catches this, so
539
+ // this is the belt-and-suspenders path when the ahead-of-base check couldn't determine it.
540
+ if (prUrl === null) {
541
+ if (job.noChangesIsError === false) {
542
+ return { pushed: false, branch: pushBranch, summary, stats, ...(usage ? { usage } : {}) };
543
+ }
544
+ return {
545
+ pushed: false,
546
+ branch: pushBranch,
547
+ summary,
548
+ stats,
549
+ error: noChangesReason('the work branch has no commits ahead of its base (nothing to open a PR for)', stats, stderrTail),
550
+ failureCause: 'no-changes',
551
+ ...(usage ? { usage } : {}),
552
+ };
553
+ }
536
554
  return { pushed: true, prUrl, branch: pushBranch, summary, stats, ...(usage ? { usage } : {}) };
537
555
  }
538
556
  return { pushed: true, branch: pushBranch, summary, stats, ...(usage ? { usage } : {}) };
@@ -1,6 +1,6 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, headCommit, listUntrackedFiles, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
3
+ import { branchAheadOfBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, headCommit, listUntrackedFiles, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
4
4
  import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
5
5
  import { acquireRepoCheckout, agentNeverActed, agentOutputTail, runAgentInWorkspace, } from './pi-workspace.js';
6
6
  import { log } from './logger.js';
@@ -238,7 +238,24 @@ export async function runCodingAgent(spec, opts = {}) {
238
238
  files: leftover.slice(0, 20),
239
239
  });
240
240
  }
241
- const hasWork = resumed || (await branchHasCommitsSince(dir, baseSha, signal));
241
+ // A fresh run produced work iff the branch advanced past its pre-run tip. A RESUMED
242
+ // run already carries prior work — UNLESS that branch turns out to have nothing ahead
243
+ // of the PR base (e.g. its earlier PR was merged with a merge commit, leaving the
244
+ // branch reachable from base and its best-effort delete skipped). Opening a PR for such
245
+ // a branch fails with GitHub's opaque 422 "No commits between ...", so a CONFIRMED-empty
246
+ // resumed branch is a no-op, not work. `undefined` (couldn't determine) keeps the prior
247
+ // resume-is-work behaviour; the PR-open path then no-ops on the 422 as a backstop.
248
+ const advancedThisPass = await branchHasCommitsSince(dir, baseSha, signal);
249
+ let hasWork = advancedThisPass || resumed;
250
+ if (resumed && !advancedThisPass) {
251
+ const ahead = await branchAheadOfBase(dir, spec.repo.baseBranch, spec.ghToken, signal);
252
+ if (ahead === false) {
253
+ logger.info('coding-agent: resumed branch has no commits ahead of base — no-op', {
254
+ base: spec.repo.baseBranch,
255
+ });
256
+ hasWork = false;
257
+ }
258
+ }
242
259
  if (!hasWork) {
243
260
  logger.info('coding-agent: no changes produced', { ...stats });
244
261
  outcome = {
package/dist/git.js CHANGED
@@ -395,6 +395,36 @@ export async function excludeFromGit(dir, pattern, signal) {
395
395
  export async function branchHasCommitsSince(dir, baseSha, signal) {
396
396
  return (await headCommit(dir, signal)) !== baseSha;
397
397
  }
398
+ /**
399
+ * Whether the checked-out branch carries at least one commit the PR base does NOT — i.e.
400
+ * `git rev-list --count <base>..HEAD > 0`. A resume clone is single-branch, so it has no
401
+ * `origin/<base>` tracking ref; this fetches the base into a dedicated local ref first and
402
+ * diffs HEAD against it.
403
+ *
404
+ * Tri-state on purpose:
405
+ * - `true` — confirmed ≥1 commit ahead (there is something to open a PR for).
406
+ * - `false` — confirmed 0 commits ahead (the branch is reachable from base, e.g. its earlier
407
+ * PR was merged with a merge commit and the best-effort branch delete was skipped).
408
+ * - `undefined` — could not determine (fetch / rev-list error); the caller keeps its prior
409
+ * behaviour rather than wrongly dropping a resumed branch that has real work.
410
+ *
411
+ * Used by the resume path to avoid declaring a merged/empty branch as work and then failing
412
+ * the run with GitHub's opaque 422 "No commits between <base> and <branch>".
413
+ */
414
+ export async function branchAheadOfBase(dir, baseBranch, ghToken, signal) {
415
+ try {
416
+ await git(['fetch', 'origin', `+refs/heads/${baseBranch}:refs/cat-factory/base`], {
417
+ cwd: dir,
418
+ signal,
419
+ env: await authEnv(ghToken),
420
+ });
421
+ const count = (await git(['rev-list', '--count', 'refs/cat-factory/base..HEAD'], { cwd: dir, signal })).trim();
422
+ return Number(count) > 0;
423
+ }
424
+ catch {
425
+ return undefined;
426
+ }
427
+ }
398
428
  /**
399
429
  * Whether the checked-out branch has a real, examinable diff against
400
430
  * `origin/<baseBranch>` — i.e. the base branch's remote-tracking ref exists (so the
@@ -749,6 +779,13 @@ export async function openPullRequest(opts) {
749
779
  if (existing)
750
780
  return existing;
751
781
  }
782
+ // The head branch has nothing ahead of base ("No commits between <base> and <head>").
783
+ // That is not an API failure — there is simply nothing to open a PR for (e.g. a resumed
784
+ // branch whose earlier PR was merged with a merge commit, leaving the branch reachable
785
+ // from base). Signal it with null so the caller records a clean no-op instead of failing
786
+ // the run with GitHub's opaque 422.
787
+ if (res.status === 422 && /no commits between/i.test(detail))
788
+ return null;
752
789
  throw new HarnessFailure('api', redactSecrets(`Failed to open PR (HTTP ${res.status}): ${detail.slice(0, 300)}`));
753
790
  }
754
791
  const body = (await res.json());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.31.6",
3
+ "version": "1.31.8",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,8 +26,8 @@
26
26
  "hono": "^4.12.27",
27
27
  "typescript": "^6.0.3",
28
28
  "vitest": "^4.1.9",
29
- "@cat-factory/spend": "0.10.69",
30
- "@cat-factory/server": "0.66.1"
29
+ "@cat-factory/server": "0.66.4",
30
+ "@cat-factory/spend": "0.10.70"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
package/src/agent.ts CHANGED
@@ -626,6 +626,28 @@ async function runCodingMode(job: AgentJob, opts: RunOptions): Promise<AgentResu
626
626
  ...(job.repo.provider ? { provider: job.repo.provider } : {}),
627
627
  signal: opts.signal,
628
628
  })
629
+ // `null` ⇒ the branch has nothing ahead of base, so there was no PR to open (a resumed
630
+ // branch whose earlier PR already merged). Record it as a clean no-op rather than a push,
631
+ // mirroring the no-changes outcome — the `runCodingAgent` guard normally catches this, so
632
+ // this is the belt-and-suspenders path when the ahead-of-base check couldn't determine it.
633
+ if (prUrl === null) {
634
+ if (job.noChangesIsError === false) {
635
+ return { pushed: false, branch: pushBranch, summary, stats, ...(usage ? { usage } : {}) }
636
+ }
637
+ return {
638
+ pushed: false,
639
+ branch: pushBranch,
640
+ summary,
641
+ stats,
642
+ error: noChangesReason(
643
+ 'the work branch has no commits ahead of its base (nothing to open a PR for)',
644
+ stats,
645
+ stderrTail,
646
+ ),
647
+ failureCause: 'no-changes',
648
+ ...(usage ? { usage } : {}),
649
+ }
650
+ }
629
651
  return { pushed: true, prUrl, branch: pushBranch, summary, stats, ...(usage ? { usage } : {}) }
630
652
  }
631
653
  return { pushed: true, branch: pushBranch, summary, stats, ...(usage ? { usage } : {}) }
@@ -2,6 +2,7 @@ import { mkdir } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
  import type { HarnessAuthFields, RepoSpec } from './job.js'
4
4
  import {
5
+ branchAheadOfBase,
5
6
  branchHasCommitsSince,
6
7
  cloneExistingBranch,
7
8
  cloneRepo,
@@ -343,7 +344,24 @@ export async function runCodingAgent(
343
344
  })
344
345
  }
345
346
 
346
- const hasWork = resumed || (await branchHasCommitsSince(dir, baseSha, signal))
347
+ // A fresh run produced work iff the branch advanced past its pre-run tip. A RESUMED
348
+ // run already carries prior work — UNLESS that branch turns out to have nothing ahead
349
+ // of the PR base (e.g. its earlier PR was merged with a merge commit, leaving the
350
+ // branch reachable from base and its best-effort delete skipped). Opening a PR for such
351
+ // a branch fails with GitHub's opaque 422 "No commits between ...", so a CONFIRMED-empty
352
+ // resumed branch is a no-op, not work. `undefined` (couldn't determine) keeps the prior
353
+ // resume-is-work behaviour; the PR-open path then no-ops on the 422 as a backstop.
354
+ const advancedThisPass = await branchHasCommitsSince(dir, baseSha, signal)
355
+ let hasWork = advancedThisPass || resumed
356
+ if (resumed && !advancedThisPass) {
357
+ const ahead = await branchAheadOfBase(dir, spec.repo.baseBranch, spec.ghToken, signal)
358
+ if (ahead === false) {
359
+ logger.info('coding-agent: resumed branch has no commits ahead of base — no-op', {
360
+ base: spec.repo.baseBranch,
361
+ })
362
+ hasWork = false
363
+ }
364
+ }
347
365
  if (!hasWork) {
348
366
  logger.info('coding-agent: no changes produced', { ...stats })
349
367
  outcome = {
package/src/git.ts CHANGED
@@ -495,6 +495,43 @@ export async function branchHasCommitsSince(
495
495
  return (await headCommit(dir, signal)) !== baseSha
496
496
  }
497
497
 
498
+ /**
499
+ * Whether the checked-out branch carries at least one commit the PR base does NOT — i.e.
500
+ * `git rev-list --count <base>..HEAD > 0`. A resume clone is single-branch, so it has no
501
+ * `origin/<base>` tracking ref; this fetches the base into a dedicated local ref first and
502
+ * diffs HEAD against it.
503
+ *
504
+ * Tri-state on purpose:
505
+ * - `true` — confirmed ≥1 commit ahead (there is something to open a PR for).
506
+ * - `false` — confirmed 0 commits ahead (the branch is reachable from base, e.g. its earlier
507
+ * PR was merged with a merge commit and the best-effort branch delete was skipped).
508
+ * - `undefined` — could not determine (fetch / rev-list error); the caller keeps its prior
509
+ * behaviour rather than wrongly dropping a resumed branch that has real work.
510
+ *
511
+ * Used by the resume path to avoid declaring a merged/empty branch as work and then failing
512
+ * the run with GitHub's opaque 422 "No commits between <base> and <branch>".
513
+ */
514
+ export async function branchAheadOfBase(
515
+ dir: string,
516
+ baseBranch: string,
517
+ ghToken: string,
518
+ signal?: AbortSignal,
519
+ ): Promise<boolean | undefined> {
520
+ try {
521
+ await git(['fetch', 'origin', `+refs/heads/${baseBranch}:refs/cat-factory/base`], {
522
+ cwd: dir,
523
+ signal,
524
+ env: await authEnv(ghToken),
525
+ })
526
+ const count = (
527
+ await git(['rev-list', '--count', 'refs/cat-factory/base..HEAD'], { cwd: dir, signal })
528
+ ).trim()
529
+ return Number(count) > 0
530
+ } catch {
531
+ return undefined
532
+ }
533
+ }
534
+
498
535
  /**
499
536
  * Whether the checked-out branch has a real, examinable diff against
500
537
  * `origin/<baseBranch>` — i.e. the base branch's remote-tracking ref exists (so the
@@ -876,7 +913,7 @@ function backoffMs(attempt: number): number {
876
913
  * GitLab whose host isn't named `gitlab.*` still opens an MR instead of being misrouted to
877
914
  * GitHub's API. The GitHub path is unchanged.
878
915
  */
879
- export async function openPullRequest(opts: OpenPullRequestOptions): Promise<string> {
916
+ export async function openPullRequest(opts: OpenPullRequestOptions): Promise<string | null> {
880
917
  const provider = opts.provider ?? (opts.cloneUrl ? inferVcsProvider(opts.cloneUrl) : 'github')
881
918
  if (provider === 'gitlab') {
882
919
  if (!opts.cloneUrl) {
@@ -917,6 +954,12 @@ export async function openPullRequest(opts: OpenPullRequestOptions): Promise<str
917
954
  const existing = await findOpenPullRequestUrl(opts)
918
955
  if (existing) return existing
919
956
  }
957
+ // The head branch has nothing ahead of base ("No commits between <base> and <head>").
958
+ // That is not an API failure — there is simply nothing to open a PR for (e.g. a resumed
959
+ // branch whose earlier PR was merged with a merge commit, leaving the branch reachable
960
+ // from base). Signal it with null so the caller records a clean no-op instead of failing
961
+ // the run with GitHub's opaque 422.
962
+ if (res.status === 422 && /no commits between/i.test(detail)) return null
920
963
  throw new HarnessFailure(
921
964
  'api',
922
965
  redactSecrets(`Failed to open PR (HTTP ${res.status}): ${detail.slice(0, 300)}`),