c8ctl-plugin-nano 1.61.0 → 1.61.1

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/c8ctl-plugin.js CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  realpathSync,
47
47
  statfsSync,
48
48
  lstatSync,
49
+ utimesSync,
49
50
  mkdtempSync,
50
51
  closeSync,
51
52
  watchFile,
@@ -2527,6 +2528,19 @@ const RESULT_SENTINEL = '::nano:result::';
2527
2528
  const RESERVED_RESULT_KEYS = new Set([
2528
2529
  AGENT_RESULT_KEY, 'output', 'exitCode', 'agent', 'truncated',
2529
2530
  'branch', 'commits', 'pushed', 'pullRequest', 'forcedReap',
2531
+ // The host-authored Git result contract (issue #231): these carry the "push
2532
+ // rejected" signal, the recovery SHAs, and the branch-mismatch detail into
2533
+ // io.nanobpm.agentResult. Reserve them alongside branch/commits/pushed so
2534
+ // untrusted agent output cannot inject/shadow them as top-level completion vars
2535
+ // and spoof the finalize contract downstream (thread 6772). `scanError` marks an
2536
+ // INCOMPLETE local scan (partial strandedCommits, no push attempted) distinctly
2537
+ // from a rejected push — reserve it too so an agent can't forge it. `pushError`
2538
+ // is the host-authored "the push was ATTEMPTED and rejected" detail — reserve it
2539
+ // alongside `pushFailed` so an untrusted agent result cannot emit a top-level
2540
+ // `pushError` that spreads into the completion vars and forges the git-failure
2541
+ // signal for consumers (the nested envelope is host-built and correct, but the
2542
+ // flat completion vars must not be spoofable).
2543
+ 'pushFailed', 'pushError', 'strandedCommits', 'branchMismatch', 'scanError',
2530
2544
  ]);
2531
2545
 
2532
2546
  // Parse `text` as a JSON object, returning it only when it is a plain object.
@@ -3850,6 +3864,12 @@ function boundGitOutput(text, max = 500) {
3850
3864
  return `${s.slice(0, head)}${marker}${s.slice(s.length - tail)}`;
3851
3865
  }
3852
3866
 
3867
+ // Collapse every line-break / vertical-whitespace run into a single space so an
3868
+ // error string (git stderr/stdout, an SDK message) that carries CR/LF can never
3869
+ // split one correlated worker-log record into uncorrelated continuation lines
3870
+ // (remote git output is repository-controlled — a log-spoofing/dilution vector).
3871
+ const oneLineLog = (v) => String(v ?? '').replace(/[\r\n\t\f\v\u0085\u2028\u2029]+/g, ' ');
3872
+
3853
3873
  // Build an informative, token-redacted failure reason from a runGit result.
3854
3874
  // Two things the old `stderr || stdout`.slice(0,500) message threw away:
3855
3875
  // 1. On a timeout Node SIGTERM-kills git (status→128, signal='SIGTERM') — say
@@ -3919,6 +3939,22 @@ function authUrl(url, provider, hasToken) {
3919
3939
 
3920
3940
  class ProvisionError extends Error {}
3921
3941
 
3942
+ // Reduce an arbitrary string to a single git-ref-safe path segment: git refname
3943
+ // rules forbid spaces and ~^:?*[\, leading/trailing/doubled dots, a trailing
3944
+ // ".lock", etc. Used to build the deterministic fallback work branch (issue #231)
3945
+ // off the base branch name + run id, so a run with no branch.create still commits
3946
+ // on a fresh, always-fast-forwardable branch instead of on the base.
3947
+ function sanitizeBranchSegment(s) {
3948
+ const cleaned = String(s == null ? '' : s)
3949
+ .replace(/[^0-9A-Za-z._-]+/g, '-') // collapse anything unusual to a dash
3950
+ .replace(/\.{2,}/g, '.') // no doubled dots (git forbids "..")
3951
+ .replace(/^[-.]+/, '') // no leading dot or dash
3952
+ .slice(0, 60) // bound the segment BEFORE the trailing
3953
+ .replace(/[-.]+$/g, '') // checks, so truncating at char 60 can't
3954
+ .replace(/\.lock$/i, 'lock'); // re-introduce a trailing dot/dash or ".lock"
3955
+ return cleaned || 'base';
3956
+ }
3957
+
3922
3958
  // Never let git invoke the host's configured credential helper for our clone/
3923
3959
  // push. Reset the helper list ("") so no helper runs — even when we DO have a
3924
3960
  // token, because helpers like `store`/keychain would persist the job's repo
@@ -4121,9 +4157,16 @@ function githubCloneToken({ provider, authRef, secretResolver, ghAuthToken = ghA
4121
4157
  // Clone repo into <runDir>/workspace and check out / create the working branch.
4122
4158
  // Returns { workspaceDir, gitEnv, startSha, workingBranch, remote }. Throws a
4123
4159
  // ProvisionError (token-redacted) on any git failure so the caller can shed.
4124
- function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
4160
+ function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, logger = null, corr = '', _runGit = null }) {
4161
+ // Test-only seam mirroring finalizeGit: route every git call through `runGitFn` so a
4162
+ // deterministic test can observe the per-call timeouts drawn from the shared
4163
+ // provisioning budget below. Defaults to the module `runGit` (production unchanged).
4164
+ const runGitFn = _runGit || runGit;
4125
4165
  const repo = envelope.repository;
4126
4166
  if (!repo || !repo.url) throw new ProvisionError('repository.url is required to provision a workspace');
4167
+ // #229: correlation suffix so git provisioning lines can be joined to the job /
4168
+ // AgentInstance / relay channels (git logs carried no elementInstanceKey before).
4169
+ const cs = corr ? ` [${corr}]` : '';
4127
4170
  const workspaceDir = join(runDir, 'workspace');
4128
4171
  const askpass = writeAskpass(runDir, token);
4129
4172
  const gitEnv = {
@@ -4168,6 +4211,21 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
4168
4211
  // Per-envelope timeout override (backstop for giant monorepos that approach the
4169
4212
  // default cap even when shallow); falls back to the caller-supplied timeout.
4170
4213
  const effectiveTimeoutMs = (repo.cloneTimeoutMs && repo.cloneTimeoutMs > 0) ? repo.cloneTimeoutMs : timeoutMs;
4214
+ // Cumulative provisioning lease budget (advisory: provisioning runs under a
4215
+ // supervisor heartbeat that CANNOT fire during the blocking spawnSync git calls
4216
+ // below). The clone PLUS the added network probes (ls-remote --symref default-branch
4217
+ // probe, the base-snapshot ls-remote, the base fetch) each formerly claimed their OWN
4218
+ // full `effectiveTimeoutMs`, so their SUM could exceed the recovery lease (default
4219
+ // 300s) and get the job REDELIVERED before the harness even started. Bound them all
4220
+ // against ONE shared deadline instead: the clone draws the full budget first (it is
4221
+ // the dominant cost and stays a retryable ProvisionError on timeout), and each
4222
+ // subsequent op takes only the REMAINING slice — so the cumulative provisioning
4223
+ // wall-time is capped by a single budget, not N×120s. A near-exhausted budget makes
4224
+ // the best-effort probes fail fast (clamped to 1ms) rather than blocking past the
4225
+ // lease; the base-snapshot/default-branch probes already degrade safely (fail-closed
4226
+ // / baseSnapshotUnknown) when they cannot complete.
4227
+ const provDeadline = Date.now() + effectiveTimeoutMs;
4228
+ const provTimeoutMs = () => Math.max(1, Math.min(effectiveTimeoutMs, provDeadline - Date.now()));
4171
4229
  const cloneArgs = [...credArgs(), 'clone', '--no-tags'];
4172
4230
  if (repo.depth && repo.depth > 0) cloneArgs.push('--depth', String(repo.depth));
4173
4231
  // `--single-branch` restricts the fetch to just `ref` — a plain `clone --branch`
@@ -4182,7 +4240,7 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
4182
4240
  const remote = authUrl(repo.url, repo.provider || 'github', !!token);
4183
4241
  cloneArgs.push(remote, workspaceDir);
4184
4242
 
4185
- const clone = runGit(cloneArgs, { env: gitEnv, timeoutMs: effectiveTimeoutMs });
4243
+ const clone = runGitFn(cloneArgs, { env: gitEnv, timeoutMs: provTimeoutMs() });
4186
4244
  if (clone.status !== 0) {
4187
4245
  if (clone.timedOut) {
4188
4246
  // Preserve whatever git managed to print before SIGTERM (plus exit/signal
@@ -4197,8 +4255,8 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
4197
4255
  if (isSha) {
4198
4256
  // The SHA may not be present under a shallow clone of the branch — fetch it
4199
4257
  // explicitly (best effort), then check it out (detached HEAD).
4200
- const fetch = runGit([...credArgs(), 'fetch', '--no-tags', 'origin', commitSha], { cwd: workspaceDir, env: gitEnv, timeoutMs: effectiveTimeoutMs });
4201
- const co = runGit(['checkout', '--detach', commitSha], { cwd: workspaceDir, env: gitEnv });
4258
+ const fetch = runGitFn([...credArgs(), 'fetch', '--no-tags', 'origin', commitSha], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4259
+ const co = runGitFn(['checkout', '--detach', commitSha], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4202
4260
  if (co.status !== 0) {
4203
4261
  // Combine the fetch + checkout output (the real reason often lives in the
4204
4262
  // fetch), and annotate a fetch timeout explicitly so a slow `git fetch
@@ -4213,6 +4271,44 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
4213
4271
  // sha is supplied, fetch it (respecting depth/filter) into a remote-tracking
4214
4272
  // ref so the harness can compute `git diff origin/<base>...HEAD`. Best-effort:
4215
4273
  // a failed base fetch is recorded, not fatal (the head clone still succeeded).
4274
+ //
4275
+ // Snapshot the CONFIGURED base's clone-time tip BEFORE the optional base fetch
4276
+ // below, so finalizeGit's pre-push base-advanced staleness check anchors on a
4277
+ // genuine clone-time baseline. The explicitly-configured base is `branch.base`
4278
+ // (or `repository.baseRef`); anything else `effectiveBase` (below) resolves to is
4279
+ // the ref the CLONE itself landed on, whose `origin/<base>` is present locally and
4280
+ // read directly there. This pre-fetch snapshot guards two hazards:
4281
+ // * a baseRef fetch fast-forwards refs/remotes/origin/<baseRef>, so reading that
4282
+ // remote-tracking ref AFTER the fetch records the already-advanced tip and
4283
+ // under-counts a real base advance (suppressed advisory 4433); and
4284
+ // * a single-branch clone of a DIFFERENT ref has NO local origin/<base>, so the
4285
+ // baseline must come from the remote — and taking that `ls-remote` AFTER the
4286
+ // fetch lets a base advance in the fetch→query window slip into the baseline
4287
+ // (TOCTOU under-count). Doing it PRE-fetch pins the true clone-time tip.
4288
+ // Capture the LOCAL remote-tracking tip when present (full clone, cheap, no
4289
+ // network); else query the remote ONCE, pre-fetch. A BRANCH base yields a SHA (the
4290
+ // baseline finalizeGit measures against); a TAG/absent base yields empty (tags are
4291
+ // immutable → the staleness skip is correct); a failed remote query leaves the
4292
+ // baseline UNKNOWN so finalizeGit reports it explicitly rather than silently
4293
+ // skipping (covers an explicit `branch.base` single-branch clone too, not only
4294
+ // `repository.baseRef`).
4295
+ const configuredBase = envelope.branch?.base
4296
+ || (repo.baseRef && !String(repo.baseRef).startsWith('-') ? String(repo.baseRef) : '');
4297
+ let baseCloneSnapshot = null; // pre-fetch clone-time baseline SHA (null ⇒ none)
4298
+ let baseSnapshotUnknown = false; // remote query failed ⇒ baseline cannot be confirmed
4299
+ if (configuredBase && !String(configuredBase).startsWith('-')) {
4300
+ const local = runGitFn(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${configuredBase}`], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4301
+ if (local.status === 0 && (local.stdout || '').trim()) {
4302
+ baseCloneSnapshot = (local.stdout || '').trim();
4303
+ } else {
4304
+ const ls = runGitFn([...credArgs(), 'ls-remote', '--heads', 'origin', `refs/heads/${configuredBase}`], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4305
+ if (ls.status === 0) {
4306
+ baseCloneSnapshot = ((ls.stdout || '').trim().split(/\s+/)[0] || '') || null; // branch → baseline; empty → tag/absent (skip)
4307
+ } else {
4308
+ baseSnapshotUnknown = true; // network failure — cannot confirm branch vs tag
4309
+ }
4310
+ }
4311
+ }
4216
4312
  let base = '';
4217
4313
  let baseFetchError;
4218
4314
  // `baseRef` (branch/tag) and `baseSha` (raw commit) are mutually exclusive — a
@@ -4239,7 +4335,7 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
4239
4335
  fetchArgs.push('origin', `+${baseTarget}:refs/remotes/origin/${baseTarget}`);
4240
4336
  base = `origin/${baseTarget}`;
4241
4337
  }
4242
- const bf = runGit(fetchArgs, { cwd: workspaceDir, env: gitEnv, timeoutMs: effectiveTimeoutMs });
4338
+ const bf = runGitFn(fetchArgs, { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4243
4339
  if (bf.status !== 0) {
4244
4340
  // Name the base target (branch vs sha) so the warning is actionable when
4245
4341
  // multiple refs are in play, and preserve git's output/context in both the
@@ -4260,8 +4356,8 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
4260
4356
  // is instead recorded as a PR comment (postAgentAttribution). Set via repo-
4261
4357
  // level config, which overrides global, so the identity is deterministic.
4262
4358
  const committer = resolveCommitterIdentity();
4263
- runGit(['config', 'user.name', committer.name], { cwd: workspaceDir, env: gitEnv });
4264
- runGit(['config', 'user.email', committer.email], { cwd: workspaceDir, env: gitEnv });
4359
+ runGitFn(['config', 'user.name', committer.name], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4360
+ runGitFn(['config', 'user.email', committer.email], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4265
4361
  // Config alone is not enough: git honours GIT_AUTHOR_*/GIT_COMMITTER_* OVER
4266
4362
  // user.name/user.email config, so a placeholder GIT_AUTHOR_EMAIL inherited from
4267
4363
  // the launch environment (e.g. `trial-merge@nano.local`) would still be stamped
@@ -4282,21 +4378,317 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
4282
4378
  // rev-parse resolves a symbolic name — a tag/sha leaves detached HEAD, in
4283
4379
  // which case there is NO branch to push and workingBranch stays null so
4284
4380
  // finalizeGit skips the push/PR reconcile instead of pushing a bogus ref.
4381
+ const log = logger || getLogger();
4382
+ // Fold the CONFIGURED base ref (`repository.baseRef`) in ahead of the checked-out
4383
+ // ref so the fallback branch NAME and the "configured base" log identify the ACTUAL
4384
+ // base. Real PR envelopes carry the base as `repository.baseRef` (not branch.base),
4385
+ // so without this the split shape (ref='feat/x' + baseRef='main', no branch.create)
4386
+ // named the fallback after the FEATURE ref (`nano/agent-work/feat-x-…`) and logged
4387
+ // 'feat/x' as the configured base even though `effectiveBase` (below) is 'main'
4388
+ // (suppressed advisory 4317). This is observability only — the #231 guard already
4389
+ // compares against `effectiveBase`, which folds the same value.
4390
+ const configuredBaseRef = repo.baseRef && !String(repo.baseRef).startsWith('-') ? String(repo.baseRef) : '';
4391
+ const baseBranchName = envelope.branch?.base || configuredBaseRef || branchName || '';
4392
+ const wantPush = coerceBool(envelope.branch?.push, true);
4285
4393
  let workingBranch = null;
4286
- if (envelope.branch?.create) {
4287
- const cb = runGit(['checkout', '-B', envelope.branch.create], { cwd: workspaceDir, env: gitEnv, timeoutMs });
4288
- if (cb.status !== 0) throw new ProvisionError(describeGitFailure(`git checkout -B ${envelope.branch.create}`, cb, { token, timeoutMs }));
4289
- workingBranch = envelope.branch.create;
4394
+ let fallbackBranch = false;
4395
+ // Cut a fresh, uniquely-named work branch off the base so commits are NEVER made
4396
+ // directly on the base branch (issue #231). Being new on the remote it always
4397
+ // fast-forwards, and its name rides back in the result envelope (env.branch) so
4398
+ // the process model can still find the work. Prefer the run's UUID (`runId`)
4399
+ // over `basename(runDir)` for the unique suffix: the latter is only unique within
4400
+ // this worker incarnation's namespace, so two workers could mint the same
4401
+ // `run-XXXXXX` basename and collide on a repo-wide remote ref (non-ff strand);
4402
+ // the UUID is globally unique. (`basename(runDir)` remains a fallback for the
4403
+ // direct-call unit tests that do not thread a runId.)
4404
+ // What the clone actually landed on: a branch only if HEAD is a SYMBOLIC ref —
4405
+ // a tag/sha leaves detached HEAD, in which case there is NO branch to push and
4406
+ // workingBranch stays null. Read it with `git symbolic-ref -q HEAD` (the FULL
4407
+ // ref, then strip `refs/heads/`), NOT `git rev-parse --abbrev-ref HEAD`: when a
4408
+ // TAG shares the branch's name (e.g. refs/tags/v1 alongside refs/heads/v1),
4409
+ // `--abbrev-ref` disambiguates by emitting the qualified 'heads/v1', which would
4410
+ // be recorded as `checkedOut` and make the returned `baseBranch` and the pre-push
4411
+ // staleness fetch target 'refs/heads/heads/v1' instead of the real base (thread
4412
+ // 4344, mirroring finalizeGit's same fix). `symbolic-ref` returns the full,
4413
+ // unambiguous ref and exits nonzero for a DETACHED HEAD → symName '' → no branch.
4414
+ const symref = runGitFn(['symbolic-ref', '-q', 'HEAD'], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4415
+ // A TIMEOUT (or spawn failure) on a MANDATORY branch-state probe must NOT be
4416
+ // misread as "detached/unborn HEAD" — under the shared provisioning budget a
4417
+ // nearly-exhausted deadline can time this probe out, and classifying that as
4418
+ // detached would return `workingBranch=null`, make `finalizeGit` SKIP the push, and
4419
+ // let the harness commit directly on the real base while the workspace is reaped:
4420
+ // silent work loss (issue #231). `runGit` surfaces a timeout as `timedOut` and a
4421
+ // spawn failure as a null status; a GENUINE detached/unborn HEAD exits 1 (a real,
4422
+ // completed status) and is unaffected. Throw a retryable ProvisionError so the job
4423
+ // is REDELIVERED instead of losing work.
4424
+ const probeFailedToRun = (r) => r.timedOut === true || r.status === null;
4425
+ if (probeFailedToRun(symref)) throw new ProvisionError(describeGitFailure('git symbolic-ref -q HEAD', symref, { token, timeoutMs }));
4426
+ const symRef = symref.status === 0 ? ((symref.stdout || '').trim()) : '';
4427
+ const symName = symRef.startsWith('refs/heads/') ? symRef.slice('refs/heads/'.length) : '';
4428
+ // A symbolic HEAD can still be UNBORN (freshly cloned EMPTY repo — a branch ref
4429
+ // pointing at no commit yet). A DETACHED checkout (tag/sha) has no symbolic ref at
4430
+ // all, but an UNBORN branch does become pushable the moment the agent makes the
4431
+ // first commit. Separate the committed vs unborn case by whether HEAD resolves to
4432
+ // a commit, so an empty-repo job is not mis-classified as detached (which would
4433
+ // skip the push and silently strand that first commit, issue #231 work-loss).
4434
+ const headProbe = runGitFn(['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4435
+ if (probeFailedToRun(headProbe)) throw new ProvisionError(describeGitFailure('git rev-parse --verify --quiet HEAD', headProbe, { token, timeoutMs }));
4436
+ const headHasCommit = headProbe.status === 0;
4437
+ const checkedOut = (symName && headHasCommit) ? symName : null; // null ⇒ detached HEAD or unborn branch
4438
+ const unbornBranch = (symName && !headHasCommit) ? symName : null;
4439
+ // The base we must never commit-and-push onto. Derive it ONLY from an explicit
4440
+ // `branch.base` or a SYMBOLIC checked-out/unborn branch — never from
4441
+ // `repository.ref`, which may be a TAG: a tag clone leaves detached HEAD
4442
+ // (checkedOut null), and if `branch.create` happened to equal that tag the old
4443
+ // `baseBranchName`-derived compare would make the guard below skip the explicit
4444
+ // checkout, leaving workingBranch null and silently skipping the push. When both
4445
+ // branch.base and repository.ref are omitted, fall back to the actually
4446
+ // checked-out ref (e.g. the remote default 'main') so an explicit
4447
+ // branch.create='main' can't slip past the guard (=== '' is false) and commit
4448
+ // directly on default — and to the UNBORN branch name for an empty-repo clone
4449
+ // (checkedOut null) so branch.create equal to the symbolic default ('master')
4450
+ // is likewise treated as the base and cut a fallback instead of committing on it.
4451
+ //
4452
+ // When `repository.sha` DETACHES HEAD (checkedOut AND unbornBranch both null) the
4453
+ // clone still landed on `repository.ref` (`branchName`) first. If that ref names a
4454
+ // REAL remote branch (e.g. ref=main + sha=<main commit> + branch.create=main), the
4455
+ // create equals a base branch and honouring the `checkout -B main` path would
4456
+ // commit and push DIRECTLY onto the base — the #231 hazard (thread 4344). Resolve
4457
+ // whether the detached ref is a remote branch and, if so, fold it into the base so
4458
+ // the guard cuts a fallback. A TAG leaves no `refs/remotes/origin/<ref>` (tags land
4459
+ // under refs/tags and are excluded by `--no-tags`), so the detached-tag case is
4460
+ // unaffected — it is exactly the branch case that must not be treated the same.
4461
+ let refBaseBranch = '';
4462
+ if (!checkedOut && !unbornBranch && branchName && !branchName.startsWith('-')) {
4463
+ const rb = runGitFn(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branchName}`], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4464
+ if (rb.status === 0 && (rb.stdout || '').trim()) refBaseBranch = branchName;
4465
+ }
4466
+ // A `repository.sha` clone with NO `repository.ref` (branchName empty) still lands
4467
+ // on the remote's DEFAULT branch before detaching, so an explicit branch.create
4468
+ // naming that default (e.g. create='main') would slip past the guard below (=== ''
4469
+ // is false) and commit/push DIRECTLY onto the base — the #231 hazard, but with no
4470
+ // ref to name it (thread 4378). Resolve the remote's default branch from
4471
+ // origin/HEAD so it too counts as a base the guard must refuse to commit onto.
4472
+ if (!refBaseBranch && !checkedOut && !unbornBranch && !branchName) {
4473
+ const dh = runGitFn(['rev-parse', '--abbrev-ref', 'origin/HEAD'], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4474
+ if (dh.status === 0) {
4475
+ const def = (dh.stdout || '').trim().replace(/^origin\//, '');
4476
+ if (def && def !== 'HEAD' && !def.startsWith('-')) refBaseBranch = def;
4477
+ }
4478
+ }
4479
+ // Fold the CONFIGURED base ref (`repository.baseRef`, the documented base) into the
4480
+ // effective base ahead of the checked-out ref. Real PR envelopes carry the base as
4481
+ // `repository.baseRef` (not `branch.base`), so without this the chain fell through
4482
+ // to `checkedOut` (the FEATURE ref) and the guard treated an explicit
4483
+ // `branch.create` that names the real base as an ordinary work branch — committing
4484
+ // and pushing directly onto the base (the #231 hazard) AND making the pre-push
4485
+ // staleness check watch the feature ref instead of the base (thread/advisory 4378).
4486
+ const effectiveBase = (envelope.branch?.base || '') || configuredBaseRef || checkedOut || unbornBranch || refBaseBranch || '';
4487
+ const cutFallbackBranch = (landedOn) => {
4488
+ const uniq = runId || basename(runDir);
4489
+ // Name the fallback segment for the REAL base/default the commit would have raced,
4490
+ // NOT the ref the clone happens to sit on. When the clone is DETACHED at a tag/SHA
4491
+ // and no base was configured, `baseBranchName` is derived from `branchName` (the
4492
+ // TAG), so an explicit `branch.create` equal to the remote default routes here with
4493
+ // an empty `effectiveBase` and would mislabel the recovery branch
4494
+ // `nano/agent-work/<tag>-…` instead of `<default>-…` (advisory: tag-base mislabels
4495
+ // fallback branch). Prefer the resolved `effectiveBase`/`remoteDefaultBranch` ahead
4496
+ // of `baseBranchName` so the segment names the real default/base, falling back to
4497
+ // the landed ref only when neither resolved.
4498
+ const baseSeg = effectiveBase || remoteDefaultBranch || baseBranchName || landedOn || 'base';
4499
+ const fb = `nano/agent-work/${sanitizeBranchSegment(baseSeg)}-${sanitizeBranchSegment(uniq)}`;
4500
+ const cb = runGitFn(['checkout', '-B', fb], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4501
+ if (cb.status !== 0) throw new ProvisionError(describeGitFailure(`git checkout -B ${fb}`, cb, { token, timeoutMs }));
4502
+ workingBranch = fb;
4503
+ fallbackBranch = true;
4504
+ // Name the branch the commit WOULD have landed on had the fallback not been cut.
4505
+ // When an explicit branch.create is present (and recognized as the base/default),
4506
+ // the unguarded path would have checked out and committed on that REQUESTED create
4507
+ // ref, NOT the ref the clone currently sits on (`landedOn`): e.g. ref=feat/x +
4508
+ // branch.create=main lands on 'feat/x' at clone but would have committed on 'main'.
4509
+ // Reporting `landedOn` alone mislabels that case (advisory: fallback-branch warning
4510
+ // names the wrong branch), so prefer `explicitCreate` when set. Also surface the
4511
+ // resolved effective/default base so the recovery diagnostic points at the branch
4512
+ // the commit would ACTUALLY have landed on and the base it would have raced.
4513
+ const wouldLandOn = explicitCreate || landedOn;
4514
+ const resolvedBase = (remoteDefaultBranch && (explicitCreate === remoteDefaultBranch || defaultUnverified))
4515
+ ? remoteDefaultBranch
4516
+ : (effectiveBase || baseBranchName || '(unknown)');
4517
+ log.warn?.(`provisionRepo${cs}: work would otherwise be committed on branch '${wouldLandOn}' (resolved base '${resolvedBase}') — cut fallback work branch '${fb}' so commits are never made directly on the base branch (the app should supply branch.create=feat/<task.id>)`);
4518
+ };
4519
+ const explicitCreate = envelope.branch?.create ? String(envelope.branch.create) : '';
4520
+ // Resolve the remote's DEFAULT branch (best-effort) ONLY when it can change the
4521
+ // decision: an explicit branch.create we would otherwise HONOR (push enabled, and
4522
+ // not already the effective base). A FEATURE-ref checkout short-circuits the
4523
+ // `refBaseBranch` (origin/HEAD) resolution above, and a job may omit the base
4524
+ // entirely, so without this an explicit `branch.create` naming the real default
4525
+ // branch (e.g. 'main') slips the base guard below and the harness commits + pushes
4526
+ // DIRECTLY onto the base — the #231 non-ff work-loss hazard (thread 4435). Try the
4527
+ // local `origin/HEAD` first; a --single-branch clone (the PR-review shape) has no
4528
+ // origin/HEAD, so fall back to a bounded `ls-remote --symref` network query. If it
4529
+ // still cannot be resolved, fall through to honoring the create — finalizeGit's
4530
+ // non-ff strand/preserve path is the backstop, so this stays best-effort, never a
4531
+ // hard failure that could wrongly reject a legitimate new work branch.
4532
+ let remoteDefaultBranch = '';
4533
+ // When default resolution was NEEDED (an explicit create we would otherwise honor)
4534
+ // but BOTH the local origin/HEAD read AND the ls-remote --symref network fallback
4535
+ // came back empty, we cannot PROVE the create is not the remote default. Fail CLOSED
4536
+ // — when the create names an ALREADY-EXISTING remote branch and no base was
4537
+ // configured: an existing remote branch could BE the default, and a push onto it
4538
+ // fast-forwards silently (the reviewer's bypass — "if that base has not advanced, the
4539
+ // push succeeds, so the new defense is bypassed"), so treat it as base-like and cut a
4540
+ // fallback. A create that does NOT exist on the remote is a genuine NEW branch: the
4541
+ // push creates it, can never non-ff a shared base, and must be honored (e.g. an empty
4542
+ // repo with an explicit `branch.create=feat/x`), so it is left alone (thread 4444).
4543
+ // Also fail CLOSED when the existence probe ITSELF fails (a transient network/auth
4544
+ // timeout): an indeterminate probe cannot prove the create is not the default, so
4545
+ // honoring it would re-open the fail-OPEN this guard exists to close (thread 4477).
4546
+ let defaultUnverified = false;
4547
+ if (explicitCreate && wantPush && explicitCreate !== effectiveBase) {
4548
+ const dh = runGitFn(['rev-parse', '--abbrev-ref', 'origin/HEAD'], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4549
+ if (dh.status === 0) {
4550
+ const def = (dh.stdout || '').trim().replace(/^origin\//, '');
4551
+ if (def && def !== 'HEAD' && !def.startsWith('-')) remoteDefaultBranch = def;
4552
+ }
4553
+ if (!remoteDefaultBranch) {
4554
+ const sr = runGitFn([...credArgs(), 'ls-remote', '--symref', 'origin', 'HEAD'], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4555
+ if (sr.status === 0) {
4556
+ const m = /^ref:\s+refs\/heads\/(\S+)\s+HEAD\b/m.exec(sr.stdout || '');
4557
+ if (m && m[1] && !m[1].startsWith('-')) remoteDefaultBranch = m[1];
4558
+ }
4559
+ }
4560
+ if (!remoteDefaultBranch && !configuredBaseRef && !envelope.branch?.base) {
4561
+ // The create could still be the unverified default — but only if it already
4562
+ // exists remotely. Probe for it; a genuinely-new branch stays honored.
4563
+ // `ls-remote` does NOT reliably accept `--end-of-options` on older git (<2.24),
4564
+ // so it is omitted here; pass the FULLY-QUALIFIED `refs/heads/<create>` pattern
4565
+ // (always 'r'-prefixed) instead of the bare name so a create beginning with '-'
4566
+ // can never be parsed as an option — same injection-safety without the flag.
4567
+ const ec = runGitFn([...credArgs(), 'ls-remote', '--heads', 'origin', `refs/heads/${explicitCreate}`], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4568
+ if (ec.status === 0) {
4569
+ const wantRef = `refs/heads/${explicitCreate}`;
4570
+ const exists = (ec.stdout || '').split('\n').some((ln) => ln.split('\t')[1] === wantRef);
4571
+ if (exists) defaultUnverified = true;
4572
+ } else {
4573
+ // The existence probe ITSELF failed (a transient network/auth timeout, the same
4574
+ // condition that emptied origin/HEAD and the --symref fallback above). We cannot
4575
+ // PROVE the create is a genuinely-new branch, so honoring it risks a silent
4576
+ // fast-forward push directly onto the unverified default — the exact fail-OPEN
4577
+ // the two successful-probe branches guard against. Treat the indeterminate probe
4578
+ // as base-like and fail CLOSED: cut the fallback (work is preserved on the
4579
+ // fallback branch, never lost) rather than committing on a possibly-shared base.
4580
+ defaultUnverified = true;
4581
+ }
4582
+ }
4583
+ }
4584
+ // An explicit create NAMES the base when it equals the effective base OR the remote's
4585
+ // resolved default branch — both mean "commit directly on a shared base". When the
4586
+ // default could NOT be verified and no base was configured, fail closed (treat every
4587
+ // such create as base-like) so an unverified default can never slip the guard (4444).
4588
+ const createNamesBase = (name) => name === effectiveBase || (!!remoteDefaultBranch && name === remoteDefaultBranch) || defaultUnverified;
4589
+ // Defense against silent work-loss (issue #231): committing on the base branch
4590
+ // with intent to push is ALWAYS wrong for the PR flow — a push to the shared
4591
+ // base races it and a non-ff reject strands the commits in this throwaway
4592
+ // workspace with no PR (re-running the agent is non-idempotent, so there is no
4593
+ // recovery). An explicit `branch.create` that NAMES the effective base (the
4594
+ // configured base, the default branch the clone landed on, OR the remote's default
4595
+ // branch when no base was given) is that same misconfiguration, so treat it like an
4596
+ // omitted create and cut a fallback rather than honouring the checkout onto the base.
4597
+ if (explicitCreate && !(wantPush && createNamesBase(explicitCreate))) {
4598
+ const cb = runGitFn(['checkout', '-B', explicitCreate], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4599
+ if (cb.status !== 0) throw new ProvisionError(describeGitFailure(`git checkout -B ${explicitCreate}`, cb, { token, timeoutMs }));
4600
+ workingBranch = explicitCreate;
4601
+ // An explicit branch.create that NAMES the effective base reaches here only
4602
+ // with push DISABLED (the push-enabled base-equal case is routed to a fallback
4603
+ // by the guard above). The agent still commits DIRECTLY on the base, so emit
4604
+ // the same base-branch warning the omitted-create read-only path does — a
4605
+ // debug-only line would leave this misconfiguration silent at normal verbosity.
4606
+ if (workingBranch === effectiveBase) log.warn?.(`provisionRepo${cs}: branch.create='${workingBranch}' IS the effective base and push is disabled → any commits land directly on the base branch (not pushed, but this throwaway workspace is reaped)`);
4607
+ else log.debug?.(`provisionRepo${cs}: branch.create=${workingBranch} → cut work branch off base '${baseBranchName || '(unknown)'}'`);
4608
+ } else if (checkedOut && wantPush) {
4609
+ // Either no branch.create, OR an explicit create that NAMES the effective base
4610
+ // while pushing — both would otherwise commit on the base, so cut a fallback.
4611
+ cutFallbackBranch(checkedOut);
4612
+ } else if (unbornBranch && wantPush) {
4613
+ // Unborn branch (empty remote clone) with push enabled: the agent's FIRST
4614
+ // commit belongs on a pushable work branch, not stranded on the unborn default.
4615
+ // Cut a fallback off the unborn branch name so finalizeGit still pushes it
4616
+ // once the harness makes that first commit.
4617
+ cutFallbackBranch(unbornBranch);
4618
+ } else if (wantPush && explicitCreate && createNamesBase(explicitCreate)) {
4619
+ // Detached HEAD (a tag/sha checkout leaves checkedOut AND unbornBranch both
4620
+ // null) where an explicit branch.create NAMES the effective base (or the remote's
4621
+ // default branch): the first `if` above suppressed the explicit checkout (to avoid
4622
+ // committing on the base), but with no symbolic branch for the arms above to fall
4623
+ // back from, workingBranch would stay null — so finalizeGit skips BOTH the push and
4624
+ // its failed-push preservation path, silently deleting any commits the harness
4625
+ // makes from the detached HEAD (issue #231 silent work-loss). Cut the fallback
4626
+ // from the CURRENT (detached) HEAD so those commits ride a pushable work
4627
+ // branch instead of being reaped with the throwaway workspace.
4628
+ cutFallbackBranch(effectiveBase);
4290
4629
  } else {
4291
- const head = runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: workspaceDir, env: gitEnv });
4292
- const name = (head.stdout || '').trim();
4293
- workingBranch = (name && name !== 'HEAD') ? name : null; // null detached HEAD
4630
+ // Detached HEAD (tag/sha null, no branch to push) or a read-only clone
4631
+ // (push disabled) safe to stay on the checked-out ref; nothing is pushed.
4632
+ // Prefer the UNBORN branch name for an empty-repo read-only clone (checkedOut
4633
+ // null, push disabled): it is a real symbolic branch, so preserving it here
4634
+ // keeps `detached: false` and exports the true `AGENT_REPO_BRANCH` (and lets
4635
+ // finalizeGit record the first commit under that branch) rather than
4636
+ // mis-reporting the empty repo as detached (suppressed advisory, line 4406).
4637
+ workingBranch = checkedOut || unbornBranch;
4638
+ if (checkedOut) {
4639
+ // push=false only prevents PUBLICATION — the agent can still commit on this
4640
+ // ref before the throwaway workspace is reaped. Promote the effective-base
4641
+ // case to warn (committing on the base is the #231 hazard even unpushed);
4642
+ // keep debug for a non-base checkout.
4643
+ if (checkedOut === effectiveBase) log.warn?.(`provisionRepo${cs}: no branch.create; push disabled → working read-only on '${checkedOut}', which IS the effective base — any commits land on the base branch (not pushed, but this throwaway workspace is reaped)`);
4644
+ else log.debug?.(`provisionRepo${cs}: no branch.create; push disabled → working read-only on '${checkedOut}'`);
4645
+ }
4646
+ }
4647
+ const sha = runGitFn(['rev-parse', 'HEAD'], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4648
+ // Capture the base ref's SHA AT CLONE TIME (issue #229/#231 observability): the
4649
+ // harness runs arbitrary code between here and finalizeGit and can itself advance
4650
+ // `refs/remotes/origin/<base>` (e.g. its own `git fetch`), so finalizeGit's
4651
+ // staleness check must compare the post-run base tip against THIS snapshot, not a
4652
+ // value re-read after the harness ran (which would misreport a real base advance
4653
+ // as zero). Best-effort — null when there is no remote-tracking base (a
4654
+ // single-branch clone of a different ref), in which case finalizeGit anchors on
4655
+ // the clone-time HEAD instead.
4656
+ let baseCloneSha = null;
4657
+ // When we could not capture a genuine pre-harness baseline for a base that MIGHT
4658
+ // be a branch, flag it so finalizeGit emits an EXPLICIT unknown-count diagnostic
4659
+ // instead of the (tag-base) silent skip. Stays false for a captured baseline or a
4660
+ // confirmed tag/absent base (tags are immutable — the silent skip is correct).
4661
+ let baseBaselineUnknown = false;
4662
+ if (effectiveBase && !effectiveBase.startsWith('-')) {
4663
+ if (configuredBase && effectiveBase === configuredBase) {
4664
+ // effectiveBase is the EXPLICITLY-configured base (`branch.base`/`baseRef`).
4665
+ // Use the PRE-FETCH snapshot captured above — NEVER a post-fetch or
4666
+ // post-harness tip (advisories 4433 / TOCTOU / singleBranch silent skip). A
4667
+ // branch base carries a baseline SHA; a tag/absent base carries null (correct
4668
+ // silent skip); a failed remote query marks the baseline unknown so finalizeGit
4669
+ // says so explicitly. This now covers an explicit `branch.base` single-branch
4670
+ // clone too, not only `repository.baseRef`.
4671
+ baseCloneSha = baseCloneSnapshot;
4672
+ baseBaselineUnknown = baseSnapshotUnknown;
4673
+ } else {
4674
+ // effectiveBase is the ref the CLONE itself landed on (no explicit
4675
+ // branch.base/baseRef) — its origin/<base> is present locally, so read the
4676
+ // clone-time tip directly.
4677
+ const br = runGitFn(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${effectiveBase}`], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
4678
+ baseCloneSha = br.status === 0 ? ((br.stdout || '').trim() || null) : null;
4679
+ }
4294
4680
  }
4295
- const sha = runGit(['rev-parse', 'HEAD'], { cwd: workspaceDir, env: gitEnv });
4296
4681
  // `git rev-parse HEAD` on an unborn branch (freshly cloned empty repo) exits
4297
4682
  // non-zero and echoes the literal "HEAD" on stdout — treat that as "no base
4298
4683
  // commit" (empty startSha) rather than a bogus revision.
4299
- return { workspaceDir, gitEnv, committer, startSha: sha.status === 0 ? (sha.stdout || '').trim() : '', workingBranch, detached: !workingBranch, ref: commitSha || branchName || '', base, baseFetchError, remote: redactToken(repo.url, token) };
4684
+ // #229/#232 observability compat: `hasPrBranch` the agent's commits land on a
4685
+ // branch DISTINCT from the effective base (a fallback/feat branch that shields
4686
+ // them from a non-ff push of a shared base). Under issue #231 provisioning ALWAYS
4687
+ // cuts such a branch when it would otherwise commit on the base, so this is true
4688
+ // exactly when a real work branch was resolved that is not the base itself
4689
+ // (false for a read-only base checkout with push disabled, or a detached HEAD).
4690
+ const hasPrBranch = !!workingBranch && workingBranch !== effectiveBase;
4691
+ return { workspaceDir, gitEnv, committer, startSha: sha.status === 0 ? (sha.stdout || '').trim() : '', workingBranch, fallbackBranch, hasPrBranch, baseBranch: effectiveBase || null, baseCloneSha, baseBaselineUnknown, detached: !workingBranch, ref: commitSha || branchName || '', base, baseFetchError, remote: redactToken(repo.url, token) };
4300
4692
  }
4301
4693
 
4302
4694
  // Look up a PR for this branch (2a does NOT open it — the harness does, driven
@@ -4410,38 +4802,593 @@ function postAgentAttribution({ workspaceDir, token, number, agentName = AGENT_A
4410
4802
  }
4411
4803
  }
4412
4804
 
4805
+ // Decide whether a finished job's throwaway run dir must be PRESERVED for
4806
+ // recovery even under the default `--keep-runs=false`. A FAILED push strands the
4807
+ // new commits in this clone's object database, so `strandedCommits` is only a
4808
+ // usable recovery handle while those objects still exist. Pure + exported so the
4809
+ // recovery guarantee is unit-tested without spinning a full worker job (issue
4810
+ // #231): the cleanup boundary calls this instead of inlining the predicate.
4811
+ function shouldPreserveRunDir(gitResult) {
4812
+ return !!gitResult?.pushFailed;
4813
+ }
4814
+
4815
+ // Decide the agentic/transcript relay's close reason (normal / job-killed / error)
4816
+ // for a finished job. Pure + exported so the close-reason contract is unit-tested
4817
+ // without spinning a full worker job. `error` must cover ANY hard finalization
4818
+ // failure: a killed run (`job-killed` takes precedence), a run that did not
4819
+ // complete, a failed harness result, OR a stranded-work finalize — the latter is
4820
+ // keyed off `pushFailed`, NOT `pushError`, because the branch-mismatch and
4821
+ // incomplete-scan paths set `pushFailed` (with `branchMismatch`/`scanError`)
4822
+ // WITHOUT attempting a push, so `pushError` is absent yet the work is preserved and
4823
+ // the loud error is logged (#229 relay close-reason).
4824
+ function computeRelayCloseReason(result, runCompleted, gitResult) {
4825
+ if (result?.aborted) return 'job-killed';
4826
+ if (!runCompleted || (result && result.ok === false) || gitResult?.pushFailed || gitResult?.pushError) return 'error';
4827
+ return 'normal';
4828
+ }
4829
+
4413
4830
  // After the harness runs: enumerate new commits, push the branch (when
4414
4831
  // branch.push), and reconcile the agent-opened PR (when task.allowPr). A push
4415
4832
  // failure is reported (pushError) rather than thrown — the process model decides
4416
4833
  // what to do next, and re-running the agent would be non-idempotent.
4417
- function finalizeGit({ workspaceDir, gitEnv, startSha, workingBranch, envelope, token }) {
4834
+ function finalizeGit({ workspaceDir, gitEnv, startSha, workingBranch, baseBranch: effectiveBase, baseCloneSha = null, baseBaselineUnknown = false, hasPrBranch, envelope, token, logger = null, corr = '', budgetMs = 0, provisioned = false, keepRuns = false, _runGit = null }) {
4835
+ const log = logger || getLogger(); // route observability through the injected logger (tests) or the host logger
4836
+ // Test-only seam: the late remote-reachability FILTER scans (movedStray /
4837
+ // unpushedBranchCommits / final stranded) AND the remote-tip VERIFICATION calls
4838
+ // (ls-remote / fetch / merge-base in the pushFailed path) are routed through
4839
+ // `lateScanGit` so a deterministic test can force ONE of them to fail (a timeout /
4840
+ // corrupt object DB / deleted remote ref in production) while the earlier enumeration
4841
+ // scans still succeed — otherwise these `--not --remotes` filters and the verification
4842
+ // probes cannot be made to fail deterministically. Defaults to the module `runGit`, so
4843
+ // production behaviour is unchanged.
4844
+ const lateScanGit = _runGit || runGit;
4845
+ const cs = corr ? ` [${corr}]` : ''; // #229 cross-channel correlation suffix
4418
4846
  const out = { branch: workingBranch, baseSha: startSha || null, headSha: null, commits: [], pushed: false, remote: null, pr: null };
4419
- const rem = runGit(['remote', 'get-url', 'origin'], { cwd: workspaceDir, env: gitEnv });
4847
+
4848
+ // Bound EVERY finalize git op against ONE shared deadline derived from the recovery
4849
+ // window (budgetMs) — the LOCAL graph scans below AND the sequential NETWORK ops
4850
+ // later. Each runs via spawnSync, which BLOCKS the event loop so the worker's
4851
+ // lock-heartbeat fiber cannot fire while it is in flight; on a very large repository
4852
+ // an unbounded local rev-list could itself burn up to the full 120s per scan before
4853
+ // the network phase even starts, letting the recovery window lapse and the broker
4854
+ // reactivate this same job mid-finalization (suppressed advisory 4670). Establish the
4855
+ // deadline BEFORE the very first git call (the `remote`/`HEAD`/commit-enumeration
4856
+ // scans here, not only the branch scans below) and draw each op's remaining slice
4857
+ // from it so their CUMULATIVE wall-time — including the later FETCH_HEAD/merge-base
4858
+ // checks — stays inside the lease (thread 4670). Enforce it STRICTLY with NO per-op
4859
+ // floor (a floor let N sequential ops each claim a minimum slice past the deadline —
4860
+ // thread 4672); clamp only to the per-op default and a 1ms minimum so a
4861
+ // near/over-exhausted budget makes the op fail-fast (spawnSync treats 0/absent as "no
4862
+ // timeout") rather than block past the window. Without a budget (direct callers /
4863
+ // unit tests) keep the full per-op timeout — behaviour-neutral (thread 4754).
4864
+ const opTimeoutMs = 120_000; // matches runGit's default; surfaced in a timeout reason
4865
+ const netDeadline = budgetMs > 0 ? Date.now() + Math.floor(budgetMs * 0.8) : 0;
4866
+ const netTimeoutMs = () => (netDeadline ? Math.min(opTimeoutMs, Math.max(1, netDeadline - Date.now())) : opTimeoutMs);
4867
+
4868
+ // Track whether any CRITICAL commit-enumeration scan did NOT complete. A rev-list
4869
+ // that fails (nonzero exit — a timeout on a huge/stuck graph, a corrupt object DB)
4870
+ // yields an EMPTY list, which is indistinguishable from a genuine "no new commits".
4871
+ // Trusting that empty result is unsafe: it lets the push gate skip BOTH the push and
4872
+ // its preservation path when the harness really did commit, and lets the promotion
4873
+ // overwrite a real recovery list with [] so a rejected push carries no stranded SHAs.
4874
+ // Treat an incomplete scan as a hard, PRESERVED finalization failure instead (thread
4875
+ // 4710); the check below the scans acts on this flag.
4876
+ let scanFailed = false, scanFailedReason = '';
4877
+ const noteScanFail = (label, r) => { if (!scanFailed) { scanFailed = true; scanFailedReason = `${label} exited ${r.status ?? 'null'}${r.signal ? ` (signal ${r.signal})` : ''}`; } };
4878
+
4879
+ const rem = runGit(['remote', 'get-url', 'origin'], { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
4420
4880
  if (rem.status === 0) out.remote = redactToken(rem.stdout.trim(), token);
4421
- const headNow = runGit(['rev-parse', 'HEAD'], { cwd: workspaceDir, env: gitEnv });
4881
+ const headNow = runGit(['rev-parse', 'HEAD'], { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
4422
4882
  out.headSha = headNow.status === 0 ? ((headNow.stdout || '').trim() || null) : null;
4423
4883
  if (startSha) {
4424
- const log = runGit(['rev-list', `${startSha}..HEAD`], { cwd: workspaceDir, env: gitEnv });
4884
+ const log = runGit(['rev-list', `${startSha}..HEAD`], { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
4425
4885
  if (log.status === 0) out.commits = log.stdout.trim().split('\n').filter(Boolean);
4886
+ else noteScanFail('rev-list startSha..HEAD', log);
4426
4887
  } else if (out.headSha) {
4427
4888
  // Empty-repo case: provisionRepo found no initial commit (unborn branch), so
4428
4889
  // there is no base to diff against — every commit now on HEAD is new. Without
4429
4890
  // this, a harness that makes the repo's first commit would enumerate as "0
4430
4891
  // commits" and the branch would never be pushed.
4431
- const log = runGit(['rev-list', 'HEAD'], { cwd: workspaceDir, env: gitEnv });
4892
+ const log = runGit(['rev-list', 'HEAD'], { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
4432
4893
  if (log.status === 0) out.commits = log.stdout.trim().split('\n').filter(Boolean);
4894
+ else noteScanFail('rev-list HEAD', log);
4895
+ }
4896
+
4897
+ // No commit exists anywhere yet: provisionRepo recorded no base (`startSha` empty)
4898
+ // AND HEAD does not resolve (an unborn work branch on an empty clone). The work-ref
4899
+ // `refs/heads/<workingBranch>` therefore has no object, so the branch-anchored scans
4900
+ // below must NOT reference it (doing so is a hard rev-list error that would falsely
4901
+ // trip `scanFailed` → a spurious `pushFailed` + leaked workspace for a no-op agent,
4902
+ // thread 4787).
4903
+ const noCommitYet = !startSha && !out.headSha;
4904
+
4905
+ // Commits made on ANY local branch that are not yet on a remote, EXCLUDING the
4906
+ // work branch we intend to push. `startSha..HEAD` (out.commits) misses work the
4907
+ // harness committed while HEAD was checked out on a DIFFERENT branch and then
4908
+ // returned HEAD to the (unmodified) work branch — HEAD is back at startSha, so
4909
+ // out.commits is empty, yet those commits are real and would be reaped with the
4910
+ // throwaway workspace (issue #231 silent work-loss, thread 4604). Anchoring on
4911
+ // `--branches --not --remotes refs/heads/<workingBranch>` enumerates exactly the
4912
+ // abandoned strays: reachable from some local branch, not yet on any remote, and
4913
+ // not carried by the branch we are about to push.
4914
+ let offBranchStray = [];
4915
+ if (workingBranch && coerceBool(envelope.branch?.push, true)) {
4916
+ // An unborn work branch (an empty clone where the harness made NO first commit)
4917
+ // has no `refs/heads/<workingBranch>` object, so feeding it to rev-list is a hard
4918
+ // error (exit 128) that spuriously trips `scanFailed` → a false `pushFailed` and a
4919
+ // leaked workspace for a genuine no-op agent (thread 4787). When there is no commit
4920
+ // anywhere (no `startSha` AND no resolvable HEAD), omit the unresolvable work-ref
4921
+ // exclusion — `--branches --not --remotes` alone exits 0/empty on an empty repo, so
4922
+ // a real off-branch stray is still detected while the no-op case stays clean.
4923
+ const strayArgs = noCommitYet
4924
+ ? ['rev-list', '--branches', '--not', '--remotes']
4925
+ : ['rev-list', '--branches', '--not', '--remotes', `refs/heads/${workingBranch}`];
4926
+ const stray = runGit(strayArgs, { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
4927
+ if (stray.status === 0) offBranchStray = stray.stdout.trim().split('\n').filter(Boolean);
4928
+ else noteScanFail('rev-list --branches --not --remotes', stray);
4929
+
4930
+ // The `--branches` walk above misses a commit that sits on NO branch at all: a
4931
+ // harness that DETACHES HEAD, commits, then checks the work branch back out (or
4932
+ // deletes the temp branch) leaves that commit reachable ONLY via the reflog. It
4933
+ // is absent from BOTH out.commits (startSha..HEAD — HEAD is back on the work
4934
+ // branch) AND offBranchStray (no branch points at it), so the push gate below
4935
+ // would see "no strays", skip preservation, and the run-dir reaper would destroy
4936
+ // the only copy of that commit (advisory: detached/reflog commit scan gap). Walk
4937
+ // the reflog for reachable-but-unpushed commits that the work-branch push would
4938
+ // NOT publish and fold them into the stray set, so they gate preservation and land
4939
+ // in strandedCommits exactly like an off-branch stray. Fail CLOSED on a scan error
4940
+ // (preserve) as with every other enumeration above.
4941
+ const reflogArgs = noCommitYet
4942
+ ? ['rev-list', '--reflog', '--not', '--remotes']
4943
+ : ['rev-list', '--reflog', '--not', '--remotes', `refs/heads/${workingBranch}`];
4944
+ const reflog = runGit(reflogArgs, { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
4945
+ if (reflog.status === 0) {
4946
+ const reflogStray = reflog.stdout.trim().split('\n').filter(Boolean);
4947
+ if (reflogStray.length) offBranchStray = [...new Set([...offBranchStray, ...reflogStray])];
4948
+ } else noteScanFail('rev-list --reflog --not --remotes', reflog);
4949
+ }
4950
+
4951
+ // Commits the work branch ITSELF carries, anchored on refs/heads/<workingBranch>
4952
+ // rather than the final HEAD. The harness may commit on the work branch and then
4953
+ // move HEAD back to the base (or detach), leaving `startSha..HEAD` (out.commits)
4954
+ // empty even though the work branch legitimately carries pushable commits. Without
4955
+ // counting them the push gate below (out.commits || offBranchStray) would be false
4956
+ // and finalizeGit would skip BOTH the push and the preservation path, letting the
4957
+ // finally-cleanup reap the only copy of the work (thread 4601).
4958
+ let branchCommits = [];
4959
+ if (workingBranch && !noCommitYet) {
4960
+ const range = startSha ? `${startSha}..refs/heads/${workingBranch}` : `refs/heads/${workingBranch}`;
4961
+ const bl = runGit(['rev-list', range], { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
4962
+ if (bl.status === 0) branchCommits = bl.stdout.trim().split('\n').filter(Boolean);
4963
+ else noteScanFail('rev-list startSha..refs/heads/<branch>', bl);
4433
4964
  }
4434
4965
 
4435
4966
  if (!workingBranch) {
4436
4967
  out.detached = true; // clone landed on a tag/sha ⇒ no branch to push
4437
- } else if (coerceBool(envelope.branch?.push, true) && out.commits.length > 0) {
4438
- const pushTimeoutMs = 120_000; // matches runGit's default; surfaced in a timeout reason
4439
- const push = runGit([...credArgs(), 'push', '--set-upstream', 'origin', workingBranch], { cwd: workspaceDir, env: gitEnv, timeoutMs: pushTimeoutMs });
4968
+ } else if (coerceBool(envelope.branch?.push, true) && scanFailed) {
4969
+ // A critical commit-enumeration scan did NOT complete (see `scanFailed` above), so
4970
+ // the empty lists it produced cannot be trusted to mean "no commits". Refuse to
4971
+ // push on an incomplete graph and PRESERVE the workspace for recovery rather than
4972
+ // silently skipping the push (and reaping the only copy of the work) or promoting
4973
+ // an empty recovery list onto a rejected push (thread 4710).
4974
+ out.pushFailed = true;
4975
+ out.scanError = scanFailedReason;
4976
+ // Surface whatever SHAs the partial scans DID reveal as a best-effort recovery
4977
+ // handle; the preserved workspace keeps the full object DB for the rest.
4978
+ const partial = [...new Set([...out.commits, ...offBranchStray, ...branchCommits])];
4979
+ if (partial.length > 0) out.strandedCommits = partial;
4980
+ log.error?.(`finalizeGit${cs}: a git graph scan did not complete (${scanFailedReason}) — refusing to push '${workingBranch}' on an incomplete commit enumeration; preserving workspace for recovery (${partial.length} partial SHA(s) surfaced).`);
4981
+ } else if (coerceBool(envelope.branch?.push, true) && (out.commits.length > 0 || offBranchStray.length > 0 || branchCommits.length > 0)) {
4982
+ const pushTimeoutMs = opTimeoutMs; // the shared per-op cap, surfaced in a timeout reason
4983
+ // The finalization deadline (netDeadline / netTimeoutMs) was established at the top
4984
+ // of finalizeGit so it also bounded the local graph scans above; the sequential
4985
+ // NETWORK ops below (pre-push staleness fetch → push → ls-remote/ff verify) simply
4986
+ // keep drawing their remaining slice from that same shared deadline.
4987
+ // Verify HEAD is still on the branch we intend to push (issue #231). The
4988
+ // harness runs arbitrary code; if it checked out a DIFFERENT ref (e.g. the
4989
+ // base branch) and committed there, out.commits (startSha..HEAD) enumerates
4990
+ // those new commits but `git push origin <workingBranch>` would push the
4991
+ // UNTOUCHED work branch and report success — after which the workspace is
4992
+ // reaped and the real commits are lost. Treat a branch mismatch as a failed,
4993
+ // preserved result: surface the stranded SHAs, skip the misdirected push (and,
4994
+ // via pushFailed, the PR reconcile) so the recovery handle survives.
4995
+ // Read the current branch via `git symbolic-ref -q HEAD`, NOT
4996
+ // `git rev-parse --abbrev-ref HEAD`. When a TAG shares the work branch's name
4997
+ // (e.g. refs/tags/v1 alongside refs/heads/v1), `--abbrev-ref` disambiguates by
4998
+ // emitting the qualified 'heads/v1' form, so `currentBranch !== workingBranch`
4999
+ // spuriously reads as a branch mismatch and finalizeGit REFUSES to push a branch
5000
+ // HEAD never actually left (thread 4767). `symbolic-ref` returns the full,
5001
+ // unambiguous `refs/heads/<name>` and exits nonzero for a DETACHED HEAD, so the
5002
+ // detached case maps cleanly to '' (no 'HEAD' sentinel to special-case).
5003
+ const headBranchNow = runGit(['symbolic-ref', '-q', 'HEAD'], { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
5004
+ const rawHeadRef = headBranchNow.status === 0 ? ((headBranchNow.stdout || '').trim()) : '';
5005
+ const currentBranch = rawHeadRef.startsWith('refs/heads/') ? rawHeadRef.slice('refs/heads/'.length) : '';
5006
+ const movedOff = currentBranch !== workingBranch;
5007
+ // Commits pushing `workingBranch` would NOT publish — the harness made them off
5008
+ // the work branch: either HEAD moved away and committed there (out.commits =
5009
+ // startSha..HEAD, meaningful ONLY when movedOff) or they were left on another
5010
+ // local branch (offBranchStray). If ANY exist, pushing the work branch would
5011
+ // publish stale work and abandon them, so refuse + preserve. But a moved/detached
5012
+ // HEAD sitting at a commit that is ALREADY on the work branch is NOT stranded:
5013
+ // `git push origin <workingBranch>` publishes it. Excluding `branchCommits` from
5014
+ // the moved-HEAD set keeps a `git checkout --detach HEAD` (after committing on
5015
+ // the work branch) from falsely refusing an otherwise-pushable branch
5016
+ // (thread 4701). When HEAD merely moved off while ALL new commits are on the
5017
+ // work branch itself (no strays), `git push origin <workingBranch>` publishes
5018
+ // exactly those commits regardless of where HEAD points — that is the intended
5019
+ // output, so push it.
5020
+ const branchCommitSet = new Set(branchCommits);
5021
+ // Moved-HEAD strays: commits reachable from the current (moved-off) HEAD that
5022
+ // `git push origin <workingBranch>` would NOT publish. Exclude commits the work
5023
+ // branch already carries (branchCommitSet) AND commits already reachable from a
5024
+ // remote: if the harness fetched e.g. `origin/main` and left HEAD at that fetched
5025
+ // tip (or pushed the work branch before moving HEAD), `startSha..HEAD` holds
5026
+ // already-PUBLISHED commits that are NOT at risk — counting them would set a
5027
+ // false `pushFailed` and suppress PR reconciliation even though no local work is
5028
+ // stranded. Anchor on `--not --remotes refs/heads/<workingBranch>` exactly as the
5029
+ // off-branch scan above does, so only genuinely unpublished, off-work-branch
5030
+ // commits count (suppressed advisory 4714). Fall back to the plain filter only if
5031
+ // the rev-list itself errors.
5032
+ let movedStray = [];
5033
+ if (movedOff) {
5034
+ const range = startSha ? `${startSha}..HEAD` : 'HEAD';
5035
+ const ms = lateScanGit(['rev-list', range, '--not', '--remotes', `refs/heads/${workingBranch}`], { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
5036
+ if (ms.status === 0) {
5037
+ movedStray = ms.stdout.trim().split('\n').filter(Boolean);
5038
+ } else {
5039
+ // The filtered walk did NOT complete, so the unfiltered fallback below cannot
5040
+ // exclude commits already reachable from a remote — it may over-report LANDED
5041
+ // commits as stranded. Fail CLOSED: mark the scan incomplete (scanError) so the
5042
+ // recovery list is flagged best-effort, never presented as an EXACT strand set
5043
+ // (advisory: fail-closed on movedStray scan failure).
5044
+ noteScanFail('rev-list startSha..HEAD --not --remotes (movedStray)', ms);
5045
+ movedStray = out.commits.filter((c) => !branchCommitSet.has(c));
5046
+ }
5047
+ }
5048
+ const strays = [...new Set([...movedStray, ...offBranchStray])];
5049
+ if (strays.length > 0) {
5050
+ out.pushFailed = true;
5051
+ // Only the UNPUBLISHED work-branch commits are actually stranded. If the harness
5052
+ // already pushed the work branch, `branchCommits` (startSha..refs/heads/<branch>)
5053
+ // are reachable from origin/<workingBranch> and are safe — unioning them
5054
+ // wholesale would falsely report published commits as UNPUSHED, preserve the run
5055
+ // unnecessarily, suppress PR reconciliation, and inflate strandedOnBranch
5056
+ // (suppressed advisory 4748). Re-derive the branch portion excluding
5057
+ // remote-reachable commits, mirroring the off-branch scan; fall back to the raw
5058
+ // list only if the rev-list itself errors.
5059
+ let unpushedBranchCommits = branchCommits;
5060
+ if (branchCommits.length > 0) {
5061
+ const range = startSha ? `${startSha}..refs/heads/${workingBranch}` : `refs/heads/${workingBranch}`;
5062
+ const ub = lateScanGit(['rev-list', range, '--not', '--remotes'], { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
5063
+ if (ub.status === 0) unpushedBranchCommits = ub.stdout.trim().split('\n').filter(Boolean);
5064
+ // Fail CLOSED on a failed filter: this scan removes commits already reachable
5065
+ // from a remote, so its unfiltered fallback (`branchCommits`) can include a
5066
+ // PUBLISHED prefix. Mark the scan incomplete (scanError) so the resulting
5067
+ // strand list is flagged best-effort, not exact (advisory: fail-closed on the
5068
+ // unpushedBranchCommits scan failure).
5069
+ else noteScanFail('rev-list startSha..refs/heads/<branch> --not --remotes (unpushedBranchCommits)', ub);
5070
+ }
5071
+ // Union every at-risk source so recovery finds them all: the off-branch strays
5072
+ // AND the UNPUBLISHED work-branch commits we are refusing to push.
5073
+ out.strandedCommits = [...new Set([...strays, ...unpushedBranchCommits])];
5074
+ // Distinguish "HEAD moved to another ref" (actual = that ref) from "HEAD is
5075
+ // back on the work branch but commits were left on ANOTHER local branch"
5076
+ // (actual null + offBranch flag) so the completion note labels the stranded
5077
+ // ref correctly instead of rendering the null as '(detached)' (suppressed
5078
+ // advisory 8746). Carry the per-ref strand split (how many stranded commits
5079
+ // sit ON the work branch vs. elsewhere) so the completion log can attribute a
5080
+ // MIXED strand accurately instead of claiming a single ref for all of them
5081
+ // (suppressed advisory 8882).
5082
+ const strandedOnBranch = unpushedBranchCommits.filter((c) => strays.indexOf(c) === -1).length;
5083
+ out.branchMismatch = movedOff
5084
+ ? { expected: workingBranch, actual: currentBranch || null, strandedOnBranch }
5085
+ : { expected: workingBranch, actual: null, offBranch: true, strandedOnBranch };
5086
+ const where = movedOff
5087
+ ? `HEAD is on '${currentBranch || '(detached)'}'`
5088
+ : `HEAD is back on '${workingBranch}' but ${offBranchStray.length} commit(s) were left on another local branch or an abandoned detached HEAD`;
5089
+ log.warn?.(`finalizeGit${cs}: ${where} — the harness moved HEAD off the provisioned work branch; refusing to push '${workingBranch}' (it would publish stale work and strand the ${out.strandedCommits.length} new commit(s)). Preserving workspace for recovery.`);
5090
+ // If either remote-reachability FILTER above (movedStray / unpushedBranchCommits)
5091
+ // did NOT complete, `strandedCommits` is a best-effort union that may include
5092
+ // already-published commits — surface that via `scanError` so a consumer can tell
5093
+ // an EXACT recovery list from a partial one (advisory: fail-closed on scan
5094
+ // failure). No push was attempted here, so there is no pushError to confuse it.
5095
+ if (scanFailed) out.scanError = out.scanError || scanFailedReason;
5096
+ } else {
5097
+ // We are pushing `workingBranch`. Promote the WORK-BRANCH tip + its commit list
5098
+ // into the authoritative output/recovery metadata BEFORE push verification
5099
+ // (threads 4646/4810, suppressed advisories 4781/4812). out.headSha (final HEAD)
5100
+ // and out.commits (startSha..HEAD) describe wherever the harness left HEAD, which
5101
+ // — in the newly supported case where it committed on the work branch then moved
5102
+ // HEAD back to the base/detached — is the BASE, not the branch we publish. Left
5103
+ // uncorrected: (1) a successful push reports the base SHA + zero commits; (2) the
5104
+ // landed-check below compares origin/<branch> against the base out.headSha, so a
5105
+ // diverged work branch can look like a descendant of the base head → false
5106
+ // landed → pushFailed suppressed → the recovery workspace deleted though the work
5107
+ // never landed; (3) a genuine non-ff reject copies an empty out.commits into
5108
+ // strandedCommits, dropping the recovery SHAs. Anchoring on refs/heads/<branch>
5109
+ // (a no-op when HEAD is already on it) makes the metadata track what we push.
5110
+ const branchTip = runGit(['rev-parse', '--verify', '--quiet', `refs/heads/${workingBranch}`], { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
5111
+ const branchTipSha = branchTip.status === 0 ? ((branchTip.stdout || '').trim() || null) : null;
5112
+ if (branchTipSha) {
5113
+ out.headSha = branchTipSha;
5114
+ out.commits = branchCommits.slice();
5115
+ }
5116
+ // advanced on the remote since we cloned, a push can be rejected non-ff. Fetch
5117
+ // the base and report how far it moved so the non-ff cause is visible in the
5118
+ // log BEFORE the push, not inferred after the fact. Best-effort — never fatal.
5119
+ // Prefer the effective base resolved during provisioning (an explicit
5120
+ // branch.base or the symbolic branch the clone landed on) so this diagnostic
5121
+ // still fires in that valid shape; only fall back to the raw envelope field if
5122
+ // it was absent.
5123
+ const baseBranch = String(effectiveBase || envelope.branch?.base || '');
5124
+ if (baseBranch && baseBranch.startsWith('-')) {
5125
+ // Git permits ref names beginning with '-', and passing an untrusted base
5126
+ // ref straight after `origin` would let a value like '--upload-pack=…' be
5127
+ // parsed as a fetch OPTION rather than a ref (argument injection). A ref that
5128
+ // begins with '-' is not a valid branch name anyway, so refuse it outright.
5129
+ log.debug?.(`finalizeGit${cs}: skipping pre-push staleness check — base ref '${baseBranch}' is not a valid branch name (begins with '-')`);
5130
+ } else if (baseBranch) {
5131
+ // Anchor the staleness range at a CLONE-TIME snapshot of the base. Prefer
5132
+ // the SHA captured during provisioning (`baseCloneSha`): the harness runs
5133
+ // between provision and finalize and can itself advance
5134
+ // `refs/remotes/origin/<base>` (e.g. its own `git fetch`), so re-reading that
5135
+ // remote-tracking ref HERE would no longer be the clone-time value and a real
5136
+ // base advance during the run would misreport as zero. Fall back to reading
5137
+ // the remote-tracking ref only for direct/legacy callers that do NOT thread a
5138
+ // provisioning snapshot (`provisioned` false). For a PROVISIONED job a null
5139
+ // `baseCloneSha` means provisioning genuinely captured no clone-time snapshot
5140
+ // (no origin/<base>, or the base fetch failed), so a live read here would be a
5141
+ // POST-harness tip — if the harness fetched the base mid-run it would read at
5142
+ // or ahead of the real tip and silently zero the staleness count, hiding a
5143
+ // non-ff cause. Skip the fallback and let the count be omitted instead
5144
+ // (suppressed advisory 4764).
5145
+ let beforeSha = baseCloneSha;
5146
+ if (!beforeSha && !provisioned) {
5147
+ const beforeRef = runGit(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${baseBranch}`], { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
5148
+ beforeSha = beforeRef.status === 0 ? ((beforeRef.stdout || '').trim() || null) : null;
5149
+ }
5150
+ if (!beforeSha) {
5151
+ // No clone-time base snapshot to measure against, so the base-advanced count
5152
+ // cannot be computed regardless — do NOT fetch. A null `beforeSha` here also
5153
+ // means there was no `refs/remotes/origin/<base>` at clone, which is exactly
5154
+ // the TAG-base case: `repository.baseRef` may be a tag, and tags land under
5155
+ // refs/tags (never a remote-tracking branch), so a `refs/heads/<base>` fetch
5156
+ // would always fail and log a misleading `stalenessFetchError` for a perfectly
5157
+ // valid tag base (suppressed advisory 4862). A tag is immutable and cannot
5158
+ // "advance" anyway, so skipping the check is the correct outcome; emit a debug
5159
+ // note so the skip is distinguishable from a diagnostic that never ran.
5160
+ //
5161
+ // Advisory #3: distinguish the case where provisioning tried to snapshot a
5162
+ // BRANCH base pre-harness but the remote query FAILED (`baseBaselineUnknown`).
5163
+ // There we cannot silently pretend the base is an immutable tag — a real base
5164
+ // advance would go unreported. Surface it as an EXPLICIT unknown-count
5165
+ // diagnostic (a distinct signal, `out.stalenessBaselineUnknown`) so a later
5166
+ // non-ff push failure is not read as an unchanged base.
5167
+ if (baseBaselineUnknown) {
5168
+ out.stalenessBaselineUnknown = true;
5169
+ log.warn?.(`finalizeGit${cs}: pre-push base-advanced check for base '${baseBranch}' is UNKNOWN — provisioning could not capture a clone-time baseline (remote query failed) and a post-harness read would be unsafe, so whether the base advanced since clone cannot be confirmed; pushing branch '${workingBranch}'`);
5170
+ } else {
5171
+ log.debug?.(`finalizeGit${cs}: no clone-time snapshot for base '${baseBranch}' (origin/${baseBranch} absent at clone — e.g. a tag base or a single-branch clone of a different ref) — skipping the base-advanced staleness check`);
5172
+ }
5173
+ } else {
5174
+ // Fetch the EXACT `refs/heads/<branch>` ref, not the bare name: git treats a
5175
+ // bare refspec beginning with '+' as the force-update prefix, so a valid
5176
+ // branch like '+release' would fetch the wrong ref and make the staleness
5177
+ // count unreliable. The `refs/heads/` prefix makes the leading char always
5178
+ // 'r', and `--end-of-options` terminates option parsing so the ref can never
5179
+ // be reinterpreted as a flag even for future/edge values.
5180
+ const bfTimeoutMs = netTimeoutMs();
5181
+ const bf = runGit([...credArgs(), 'fetch', '--no-tags', '--end-of-options', 'origin', `refs/heads/${baseBranch}`], { cwd: workspaceDir, env: gitEnv, timeoutMs: bfTimeoutMs });
5182
+ if (bf.status === 0) {
5183
+ const fetched = runGit(['rev-parse', '--verify', '--quiet', 'FETCH_HEAD'], { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
5184
+ const fetchedSha = fetched.status === 0 ? ((fetched.stdout || '').trim() || null) : null;
5185
+ // Measure how far the BASE advanced since we cloned, not how far our post-
5186
+ // work HEAD sits behind the new base tip: `beforeSha` is the base ref as of
5187
+ // clone, so `beforeSha..FETCH_HEAD` counts exactly the commits the base
5188
+ // gained.
5189
+ {
5190
+ const ahead = runGit(['rev-list', '--count', `${beforeSha}..FETCH_HEAD`], { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
5191
+ const n = ahead.status === 0 ? parseInt((ahead.stdout || '').trim(), 10) : NaN;
5192
+ if (Number.isFinite(n) && n > 0) {
5193
+ out.baseAdvanced = n;
5194
+ const shaNote = ` [base '${baseBranch}': ${beforeSha.slice(0, 12)} → ${fetchedSha ? fetchedSha.slice(0, 12) : '(unknown)'}]`;
5195
+ log.warn?.(`finalizeGit${cs}: remote base '${baseBranch}' advanced ${n} commit(s) since clone${shaNote} — pushing branch '${workingBranch}'${workingBranch === baseBranch ? ' (which IS the base — a non-ff reject would strand the commits)' : ''}`);
5196
+ } else if (Number.isFinite(n)) {
5197
+ // Record the CLEAN result too (n === 0), so a base that did not advance is
5198
+ // distinguishable from a skipped or errored check in the logs rather than
5199
+ // leaving silence that reads as "diagnostic never ran" (advisory 4847).
5200
+ log.debug?.(`finalizeGit${cs}: remote base '${baseBranch}' unchanged since clone (0 new commit(s)) — pushing branch '${workingBranch}'`);
5201
+ } else {
5202
+ // The staleness count could NOT be computed — `rev-list --count` failed
5203
+ // (timeout / incomplete graph) or returned a non-numeric result. Log it as
5204
+ // UNKNOWN, NOT as an unchanged base: collapsing a failed probe to 0 would
5205
+ // fabricate a clean "0 new commit(s)" and hide the exact base advance this
5206
+ // diagnostic exists to expose, so a later non-ff push failure reads as an
5207
+ // unchanged base (advisory 4985). Best-effort — still never fatal.
5208
+ log.warn?.(`finalizeGit${cs}: pre-push staleness count for base '${baseBranch}' is UNKNOWN (git rev-list --count exited ${ahead.status ?? 'null'}) — cannot confirm whether the base advanced since clone; pushing branch '${workingBranch}'`);
5209
+ }
5210
+ }
5211
+ } else {
5212
+ // The pre-push staleness diagnostic disappears exactly when the best-effort
5213
+ // fetch fails: a subsequent non-ff (or other) push failure is then
5214
+ // indistinguishable from an unchanged base. Surface the fetch failure so
5215
+ // that lost observability is itself visible (issue #229/#231). Best-effort
5216
+ // — still never fatal.
5217
+ out.stalenessFetchError = describeGitFailure(`git fetch origin refs/heads/${baseBranch}`, bf, { token, timeoutMs: bfTimeoutMs });
5218
+ log.warn?.(`finalizeGit${cs}: pre-push staleness check could not fetch base '${baseBranch}' (${out.stalenessFetchError}) — a later non-ff or other push failure will be indistinguishable from an unchanged base`);
5219
+ }
5220
+ }
5221
+ }
5222
+ // Push the EXPLICIT refs/heads/<branch>:refs/heads/<branch> refspec, not the bare
5223
+ // `workingBranch`: a bare source ref is ambiguous when a same-named TAG also
5224
+ // exists (e.g. workingBranch 'v1' while origin — and thus the local clone — has
5225
+ // refs/tags/v1), so `git push origin v1` fails "src refspec v1 matches more than
5226
+ // one" and misreports the work as unpublished (threads 4767/4777). The fully
5227
+ // qualified heads ref resolves unambiguously; --set-upstream still tracks the
5228
+ // local branch.
5229
+ const pushTimeout = netTimeoutMs();
5230
+ const push = runGit([...credArgs(), 'push', '--set-upstream', 'origin', `refs/heads/${workingBranch}:refs/heads/${workingBranch}`], { cwd: workspaceDir, env: gitEnv, timeoutMs: pushTimeout });
4440
5231
  if (push.status === 0) out.pushed = true;
4441
- else out.pushError = describeGitFailure('git push', push, { token, timeoutMs: pushTimeoutMs });
5232
+ else {
5233
+ // A nonzero `git push` does NOT prove the ref was not updated: a timeout or
5234
+ // connection drop can occur AFTER the server accepted the ref. Before
5235
+ // labeling the commits stranded (and suppressing PR reconciliation), verify
5236
+ // the remote — if origin/<branch> already points at our headSha, the push
5237
+ // actually landed and this was a transport hiccup, so report success rather
5238
+ // than misreporting published work as lost (issue #231).
5239
+ let landed = false;
5240
+ // The work branch's VERIFIED remote tip whose OBJECT is local (fetched below),
5241
+ // so it is safe to feed to `rev-list --not`. The clone-time refs/remotes/origin/
5242
+ // <branch> is NOT refreshed by the fetch below (that updates FETCH_HEAD only), so
5243
+ // the strand walk must exclude against THIS verified tip rather than trust the
5244
+ // stale remote-tracking ref (advisory: stranded list uses a stale remote ref).
5245
+ let verifiedRemoteTip = null;
5246
+ // Did the remote-tip VERIFICATION itself fail (ls-remote could not query, or the
5247
+ // remote tip is known-different but the follow-up fetch failed)? If so, the
5248
+ // clone-time refs/remotes/origin/<branch> the strand filter falls back to is
5249
+ // KNOWN-STALE, so the resulting strand list is best-effort — surface that via
5250
+ // scanError below rather than presenting it as exact (advisory: fail-closed on
5251
+ // remote-tip verification failure, not just the final rev-list).
5252
+ let remoteTipVerifyFailed = false;
5253
+ if (out.headSha) {
5254
+ // Query the EXACT `refs/heads/<branch>` ref (with `--heads`) so a same-named
5255
+ // TAG at headSha can't spoof a "landed" head: a bare `<branch>` pattern makes
5256
+ // `ls-remote` match tags too, which would falsely suppress pushFailed. The
5257
+ // fully-qualified `refs/heads/` pattern is always 'r'-prefixed, so no
5258
+ // `--end-of-options` is needed (and `ls-remote` doesn't reliably accept it on
5259
+ // git <2.24).
5260
+ const ls = lateScanGit([...credArgs(), 'ls-remote', '--heads', 'origin', `refs/heads/${workingBranch}`], { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
5261
+ if (ls.status === 0) {
5262
+ const remoteSha = ((ls.stdout || '').trim().split(/\s+/)[0] || '');
5263
+ if (remoteSha && remoteSha === out.headSha) {
5264
+ landed = true; // exact-match fast path
5265
+ } else if (remoteSha) {
5266
+ // The remote tip differs from our head but may be a DESCENDANT of it:
5267
+ // another actor (or the harness) pushed a further commit on the SAME
5268
+ // branch after ours landed, so `origin/<branch>` is ahead of `headSha`
5269
+ // and our work is already published — not stranded. Fetch the ref so its
5270
+ // objects are local, then test `headSha` is an ancestor of the remote
5271
+ // tip. Best-effort: a failed fetch/ancestry check just falls through to
5272
+ // the strand path (issue #231, suppressed advisory 4689).
5273
+ const ff = lateScanGit([...credArgs(), 'fetch', '--no-tags', '--end-of-options', 'origin', `refs/heads/${workingBranch}`], { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
5274
+ if (ff.status === 0) {
5275
+ // The fetch made `remoteSha`'s object local (via FETCH_HEAD). Record it
5276
+ // as the verified remote tip so the strand walk can exclude commits
5277
+ // already reachable from the CURRENT remote tip even though the
5278
+ // clone-time refs/remotes/origin/<branch> was never refreshed.
5279
+ verifiedRemoteTip = remoteSha;
5280
+ const anc = lateScanGit(['merge-base', '--is-ancestor', out.headSha, remoteSha], { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
5281
+ if (anc.status === 0) landed = true;
5282
+ } else {
5283
+ // The remote tip is known-DIFFERENT from headSha, but we could not fetch
5284
+ // it to verify ancestry — so the strand filter cannot exclude against the
5285
+ // real tip and must fall back to the STALE clone-time tracking ref.
5286
+ remoteTipVerifyFailed = true;
5287
+ }
5288
+ } else {
5289
+ // `ls-remote` succeeded but returned NO sha for refs/heads/<branch> — the
5290
+ // branch was DELETED on the remote between clone and this verification. The
5291
+ // clone-time refs/remotes/origin/<branch> is now stale (points at a tip that
5292
+ // no longer exists), so a `--not --remotes` walk against it can omit commits
5293
+ // reachable only through that dangling tracking ref and present an INCOMPLETE
5294
+ // strand list as exact. Treat an empty result as an UNVERIFIED remote tip so
5295
+ // the list is flagged best-effort (scanError) and the workspace is preserved.
5296
+ remoteTipVerifyFailed = true;
5297
+ }
5298
+ } else {
5299
+ // ls-remote could not query the remote at all — we cannot confirm whether the
5300
+ // clone-time tracking ref is current, so the strand fallback is best-effort.
5301
+ remoteTipVerifyFailed = true;
5302
+ }
5303
+ }
5304
+ if (landed) {
5305
+ out.pushed = true;
5306
+ log.warn?.(`finalizeGit${cs}: 'git push' exited nonzero but origin/'${workingBranch}' already contains ${out.headSha.slice(0, 12)} (at or ahead of it) — treating as a transport hiccup after the ref was accepted, not a strand`);
5307
+ } else {
5308
+ // A rejected push (typically non-fast-forward) would otherwise strand every
5309
+ // new commit in this throwaway workspace. Surface the at-risk SHAs explicitly
5310
+ // (issue #231, defense #2) so the failure is a hard, actionable signal with
5311
+ // the commits to recover — not a soft pushError that reads as "completed".
5312
+ out.pushError = describeGitFailure('git push', push, { token, timeoutMs: pushTimeout });
5313
+ out.pushFailed = true;
5314
+ // Union the work-branch commit list into the stranded set: out.commits is now
5315
+ // the promoted branchCommits, but if HEAD sat off the branch and promotion
5316
+ // found no tip, out.commits could still be the (empty) final-HEAD range —
5317
+ // branchCommits carries the commits the rejected push actually strands, so
5318
+ // include them explicitly (suppressed advisory 4812). But a NON-FF reject does
5319
+ // NOT mean the whole branch is unpushed: an earlier prefix may already be on
5320
+ // the remote (e.g. C1 landed, local C2 followed, another actor advanced the
5321
+ // remote from C1, so pushing C2 is rejected — yet C1 is already recoverable).
5322
+ // Reporting C1 as stranded is a false alarm, so filter the union against every
5323
+ // remote-tracking ref (`--not --remotes`) exactly as the branch-mismatch path
5324
+ // does, leaving only the genuinely unpublished commits; fall back to the raw
5325
+ // union only if the rev-list itself errors (suppressed advisory 4960). The
5326
+ // clone-time refs/remotes/origin/<branch> is STALE (never refreshed from the
5327
+ // verified tip above), so `--not --remotes` alone could list an already-landed
5328
+ // commit — another actor's post-clone push — as UNPUSHED; also exclude against
5329
+ // the VERIFIED remote tip whose object we fetched (advisory: stranded list uses
5330
+ // a stale remote-tracking ref).
5331
+ let stranded = [...new Set([...out.commits, ...branchCommits])];
5332
+ // The strand filter excludes against the VERIFIED remote tip when we have it;
5333
+ // without it (verification failed) it falls back to the clone-time tracking ref,
5334
+ // which — when `remoteTipVerifyFailed` — is KNOWN-STALE, so the resulting list is
5335
+ // best-effort even if the rev-list itself succeeds.
5336
+ const strandFilterStale = !verifiedRemoteTip && remoteTipVerifyFailed;
5337
+ if (stranded.length > 0) {
5338
+ const strandExcludes = verifiedRemoteTip
5339
+ ? ['--not', '--remotes', verifiedRemoteTip]
5340
+ : ['--not', '--remotes'];
5341
+ const sr = lateScanGit(['rev-list', ...stranded, ...strandExcludes], { cwd: workspaceDir, env: gitEnv, timeoutMs: netTimeoutMs() });
5342
+ if (sr.status === 0) stranded = sr.stdout.trim().split('\n').filter(Boolean);
5343
+ // Fail CLOSED: if the remote-reachability filter did NOT complete, the
5344
+ // unfiltered union stays as `stranded` — which can falsely label
5345
+ // already-remote commits as unpushed. Surface the incomplete scan via
5346
+ // `scanError` so a consumer distinguishes an EXACT recovery list from this
5347
+ // best-effort fallback (advisory: fail-closed on the final stranded scan).
5348
+ else noteScanFail('rev-list <stranded> --not --remotes', sr);
5349
+ }
5350
+ out.strandedCommits = stranded;
5351
+ // Even when the rev-list SUCCEEDED, a failed remote-tip verification means it
5352
+ // filtered against a stale clone-time ref — so the list is best-effort. Mark it
5353
+ // scanError too (advisory: fail-closed on remote-tip VERIFICATION failure, not
5354
+ // just the final rev-list).
5355
+ if (strandFilterStale && !scanFailed) {
5356
+ scanFailed = true;
5357
+ scanFailedReason = 'remote-tip verification (ls-remote/fetch) failed — strand filter used stale clone-time tracking refs';
5358
+ }
5359
+ if (scanFailed) out.scanError = out.scanError || scanFailedReason;
5360
+ // #229/#232 observability: elevate the push failure to a LOUD, correlated
5361
+ // signal. `hasPrBranch` (threaded from provisionRepo, which knows the branch
5362
+ // the clone ACTUALLY landed on) says whether the stranded commits sit on a
5363
+ // DISTINCT work branch; fall back to the envelope heuristic only for direct
5364
+ // callers that don't thread it. Even a distinct branch's commits are LOST when
5365
+ // the throwaway run workspace is reaped, UNLESS `--keep-runs` retains it — so
5366
+ // the wording distinguishes cause (no PR branch vs. reaped workspace) and fate
5367
+ // (LOST vs. retained).
5368
+ const prBranch = typeof hasPrBranch === 'boolean'
5369
+ ? hasPrBranch
5370
+ : (!!envelope.branch?.create && workingBranch !== (envelope.repository?.ref || envelope.branch?.base || ''));
5371
+ const n = out.strandedCommits.length;
5372
+ const lossNote = prBranch
5373
+ ? (keepRuns
5374
+ ? `${n} commit(s) are unpushed on branch '${workingBranch}' and retained in the run workspace (--keep-runs)`
5375
+ : `${n} commit(s) are unpushed on branch '${workingBranch}' and will be LOST when the run workspace is reaped (re-run with --keep-runs to retain them)`)
5376
+ : (keepRuns
5377
+ ? `${n} commit(s) are unpushed (no PR branch) but retained in the run workspace (--keep-runs)`
5378
+ : `${n} commit(s) are unpushed and will be LOST (no PR branch, and the run workspace is reaped — re-run with --keep-runs to retain them)`);
5379
+ log.error?.(`git finalize: push of branch '${workingBranch}' FAILED — ${lossNote}: ${oneLineLog(out.pushError)}${cs}`);
5380
+ }
5381
+ }
5382
+ } // end HEAD-on-workingBranch else
4442
5383
  }
4443
5384
 
4444
- if (workingBranch && envelope.task?.allowPr) {
5385
+ // Skip PR reconciliation when the push FAILED (issue #231): a non-fast-forward
5386
+ // reject leaves this run's commits stranded, but an explicit work branch may
5387
+ // still have an OLDER remote PR — reporting `pr.found` for it would falsely
5388
+ // claim this run's stranded commits are in a PR. Leave `out.pr` unset so the
5389
+ // envelope + completion note carry only the `pushFailed`/`strandedCommits`
5390
+ // recovery signal, not a misleading PR reference.
5391
+ if (workingBranch && envelope.task?.allowPr && !out.pushFailed) {
4445
5392
  out.pr = reconcileAgentPr({ workspaceDir, token, branch: workingBranch, provider: envelope.repository?.provider || 'github' });
4446
5393
  // Record the agent's authorship on the PR (commits carry the operator's
4447
5394
  // identity now, so this preserves the machine-generated provenance).
@@ -6210,6 +7157,27 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult,
6210
7157
  env.commits = git.commits ?? [];
6211
7158
  env.pushed = !!git.pushed;
6212
7159
  if (git.pushError) env.pushError = git.pushError;
7160
+ // Forward the explicit failure flag AND the stranded SHAs together: consumers
7161
+ // of io.nanobpm.agentResult key off `pushFailed` for the hard "push rejected"
7162
+ // signal, and `strandedCommits` carries the SHAs to recover (see README).
7163
+ if (git.pushFailed) env.pushFailed = true;
7164
+ if (git.strandedCommits && git.strandedCommits.length) env.strandedCommits = git.strandedCommits;
7165
+ // Distinguish an INCOMPLETE local commit scan from a real rejected push. The
7166
+ // incomplete-graph path in finalizeGit sets `pushFailed=true` even though NO push
7167
+ // was attempted (a critical rev-list did not complete), recording the cause in
7168
+ // `scanError` — and in that case `strandedCommits` may be only PARTIAL. Forwarding
7169
+ // only `pushFailed`/`strandedCommits` makes (a) a rejected push and (b) an
7170
+ // incomplete scan look identical, so a consumer cannot tell that the stranded list
7171
+ // is best-effort and the run must be recovered manually (advisory: envelope drops
7172
+ // scanError). Thread `scanError` through so the two are distinguishable; a plain
7173
+ // rejected push carries `pushError`, not `scanError`.
7174
+ if (git.scanError) env.scanError = git.scanError;
7175
+ // Forward the branch-mismatch detail too (thread 6530): for a branch-mismatch
7176
+ // refusal the README/finalizeGit contract lists `branchMismatch` as part of the
7177
+ // envelope, and it is the only field carrying the expected-vs-actual ref a
7178
+ // consumer needs to route recovery — the SHAs alone don't say which ref they sit
7179
+ // on. Without this the mismatch reason is dropped from io.nanobpm.agentResult.
7180
+ if (git.branchMismatch) env.branchMismatch = git.branchMismatch;
6213
7181
  if (git.pr) env.pr = git.pr;
6214
7182
  if (git.error) env.gitError = git.error;
6215
7183
  }
@@ -7771,7 +8739,7 @@ async function workAgent(req, flags) {
7771
8739
  // connection instead of a per-process channel.
7772
8740
  /** @type {import('./supervisor.dist.js').AgenticEndpoint | null} */
7773
8741
  let agenticEndpoint = null;
7774
- /** @type {{ register: () => void, deregister: (reason?: string) => void, relaySessionFor: (jobKey: string|number) => (object|null) } | null} */
8742
+ /** @type {{ register: () => void, deregister: (reason?: string) => void, relaySessionFor: (jobKey: string|number, extra?: { elementInstanceKey?: string|number, processInstanceKey?: string|number, agentInstanceKey?: string|number|(() => string|number|undefined) }) => (object|null) } | null} */
7775
8743
  let agenticPlane = null;
7776
8744
  // The presence attributes this worker announces on `register` (ENROLMENT
7777
8745
  // attributes, not routing tokens — jobKeys are carried by the explicit
@@ -8097,9 +9065,31 @@ async function workAgent(req, flags) {
8097
9065
  // fires exactly as before regardless.
8098
9066
  let agentInstanceProducer = null;
8099
9067
  const agentInstanceOff = String(process.env.NANO_AGENT_INSTANCE || '').trim().toLowerCase() === 'off';
9068
+ // #229: correlation stamp for the decision-point + outer-catch logs below,
9069
+ // so the AgentInstance producer lifecycle can be joined to the relay/git/job
9070
+ // channels (elementInstanceKey + processInstanceKey today live only on the
9071
+ // job, never on these lines).
9072
+ const aiCorr = `job ${job.jobKey} eik ${job.elementInstanceKey ?? '?'} pik ${job.processInstanceKey ?? '?'}`;
8100
9073
  if (!agentInstanceOff && isExternalAgentJob(job)) {
8101
9074
  agentInstanceProducer = createAgentInstanceProducer({ camunda, job, profile, envelope, logger });
8102
- try { await agentInstanceProducer.activate(); } catch { /* best effort */ }
9075
+ try {
9076
+ // `createAgentInstanceProducer` always returns an object — including a
9077
+ // DISABLED facade when the host SDK lacks createAgentInstance/
9078
+ // updateAgentInstance. Report based on the ACTUAL activation outcome
9079
+ // (`active`), not the mere fact a producer object exists, so an
9080
+ // unavailable/rejected producer doesn't masquerade as a usable one and
9081
+ // leave the no-transcript cause silent.
9082
+ const active = await agentInstanceProducer.activate();
9083
+ if (active) {
9084
+ logger.info(`[${jobType}] AgentInstance producer active for external agent job (${aiCorr}).`);
9085
+ } else {
9086
+ logger.info(`[${jobType}] AgentInstance producer unavailable for external agent job (${aiCorr}) — activation not attempted or rejected: the host SDK lacks createAgentInstance/updateAgentInstance, the ACP classifier is unavailable, createAgentInstance returned no key, or the SDK rejected the create; continuing without a durable transcript (job completion unaffected).`);
9087
+ }
9088
+ } catch (err) {
9089
+ logger.warn(`[${jobType}] AgentInstance producer activate() threw (${aiCorr}) — ${oneLineLog(err?.message || err)}; continuing without a durable transcript (job completion unaffected).`);
9090
+ }
9091
+ } else {
9092
+ logger.debug?.(`[${jobType}] AgentInstance producer skipped (${aiCorr}) — ${agentInstanceOff ? 'NANO_AGENT_INSTANCE=off' : 'not an external agent job (no lease token / elementInstanceKey)'}.`);
8103
9093
  }
8104
9094
 
8105
9095
  // Fail-closed on a half-specified repository envelope (issue #129,
@@ -8145,9 +9135,9 @@ async function workAgent(req, flags) {
8145
9135
  mkdirSync(workerNsDir, { recursive: true });
8146
9136
  runDir = mkdtempSync(join(workerNsDir, 'run-'));
8147
9137
  liveRunDirs.add(runDir);
8148
- provisioned = provisionRepo({ envelope, token: repoToken, runDir, timeoutMs: cloneTimeoutMs });
9138
+ provisioned = provisionRepo({ envelope, token: repoToken, runDir, runId, timeoutMs: cloneTimeoutMs, logger, corr: aiCorr });
8149
9139
  if (provisioned.baseFetchError) {
8150
- logger.warn(`[${jobType}] job ${job.jobKey} base fetch failed — ${provisioned.baseFetchError}; base...head diffs may be unavailable`);
9140
+ logger.warn(`[${jobType}] job ${job.jobKey} base fetch failed (${aiCorr}) — ${oneLineLog(provisioned.baseFetchError)}; base...head diffs may be unavailable`);
8151
9141
  }
8152
9142
  cwd = provisioned.workspaceDir;
8153
9143
  extraEnv = {
@@ -8174,7 +9164,13 @@ async function workAgent(req, flags) {
8174
9164
  if (isContainer) liveRunIds.delete(runId);
8175
9165
  const retries = Math.max(0, (Number(job.retries) || 1) - 1);
8176
9166
  const msg = err instanceof ProvisionError ? err.message : `provisioning error: ${err.message}`;
8177
- logger.warn(`[${jobType}] job ${job.jobKey} not provisioned${msg}; retries left ${retries}`);
9167
+ // #229: include the correlation keys a clone/checkout failure occurs
9168
+ // before provisionRepo reaches either branch-decision log, so this is the
9169
+ // ONLY git-provisioning signal and must be joinable to the other channels.
9170
+ // Normalize ONLY the logged value to one line (git stderr/stdout preserved
9171
+ // by gitErrorDetail is multiline) so continuation lines can't split this
9172
+ // correlated record; the settlement error below keeps the full detail.
9173
+ logger.warn(`[${jobType}] job ${job.jobKey} not provisioned (${aiCorr}) — ${oneLineLog(msg)}; retries left ${retries}`);
8178
9174
  return settleJob.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
8179
9175
  }
8180
9176
  } else if (!isContainer) {
@@ -8207,6 +9203,16 @@ async function workAgent(req, flags) {
8207
9203
 
8208
9204
  let result;
8209
9205
  let gitResult = null;
9206
+ // #229: a finalizeGit throw is an ERROR outcome even though `result.ok` was
9207
+ // true — track it so the relay close reason below is 'error', not 'normal'.
9208
+ let gitFinalizeFailed = false;
9209
+ // #229: track whether the run + finalization actually ran to completion. The
9210
+ // relay close reason must NOT be inferred from `result` alone: if
9211
+ // runAgentJob (or any later step) throws before `result` is assigned, the
9212
+ // finally runs with `result === undefined` and would mislabel an ERRORED run
9213
+ // as a `normal` close. This flag flips true only once we reach the end of the
9214
+ // try body, so an early throw is correctly reported as `error`.
9215
+ let runCompleted = false;
8210
9216
  // The per-job live-terminal relay session (issue #173): streams this job's
8211
9217
  // harness terminal over the single-owner supervisor's ONE multiplexed host
8212
9218
  // connection, keyed by this worker's instance + the jobKey, and accepts
@@ -8215,7 +9221,14 @@ async function workAgent(req, flags) {
8215
9221
  // in the finally so its steer subscription never leaks across jobs.
8216
9222
  let relaySession = null;
8217
9223
  if (agenticPlane) {
8218
- relaySession = agenticPlane.relaySessionFor(job.jobKey);
9224
+ // #229: thread the correlation join keys + a lazy AgentInstance-key getter
9225
+ // so the relay's open/close/first-update logs can be reconciled against the
9226
+ // engine (AgentInstance), job and git channels.
9227
+ relaySession = agenticPlane.relaySessionFor(job.jobKey, {
9228
+ elementInstanceKey: job.elementInstanceKey,
9229
+ processInstanceKey: job.processInstanceKey,
9230
+ agentInstanceKey: () => agentInstanceProducer?.agentInstanceKey,
9231
+ });
8219
9232
  }
8220
9233
  // Private structured-result channel: hand the agent a file (outside any
8221
9234
  // repo clone so it can't be `git add`ed) to write its job-result vars to.
@@ -8337,7 +9350,7 @@ async function workAgent(req, flags) {
8337
9350
  // job end (a failed run leaves it non-terminal so a retry/reactivation
8338
9351
  // continues the same instance). Best-effort — never disturbs job settlement.
8339
9352
  if (agentInstanceProducer) {
8340
- try { await agentInstanceProducer.complete(result.ok); } catch { /* best effort */ }
9353
+ try { await agentInstanceProducer.complete(result.ok); } catch (err) { logger.warn(`[${jobType}] AgentInstance producer complete() threw (${aiCorr}) — ${oneLineLog(err?.message || err)}; job settlement unaffected.`); }
8341
9354
  }
8342
9355
 
8343
9356
  // Finalize git only when the harness succeeded — never push a
@@ -8349,29 +9362,118 @@ async function workAgent(req, flags) {
8349
9362
  gitEnv: provisioned.gitEnv,
8350
9363
  startSha: provisioned.startSha,
8351
9364
  workingBranch: provisioned.workingBranch,
9365
+ baseBranch: provisioned.baseBranch,
9366
+ baseCloneSha: provisioned.baseCloneSha,
9367
+ baseBaselineUnknown: provisioned.baseBaselineUnknown,
9368
+ hasPrBranch: provisioned.hasPrBranch,
8352
9369
  envelope,
8353
9370
  token: repoToken,
9371
+ logger,
9372
+ corr: aiCorr,
9373
+ keepRuns,
9374
+ // Cap finalization's cumulative network-git wall-time below the
9375
+ // worker's activation-lock/recovery window so its event-loop-blocking
9376
+ // spawnSync ops cannot outlast the lease and let this job be
9377
+ // reactivated mid-finalization (thread 4754). Use the WORKER-level
9378
+ // `recoveryWindowMs`, NOT the per-task `effectiveRecoveryWindowMs`: a
9379
+ // task override widens only the harness liveness window, not the
9380
+ // broker lock (see #172 note above + dispatch config below), so
9381
+ // budgeting against the widened value could still let the git ops run
9382
+ // past the real lease (thread 8811). SUBTRACT `lockExtendIntervalMs`:
9383
+ // the lock is renewed to a full `recoveryWindowMs` only at each
9384
+ // heartbeat, and the heartbeat fiber cannot fire while finalization's
9385
+ // spawnSync ops block the event loop. At finalization start the last
9386
+ // beat could have fired up to `lockExtendIntervalMs` ago, so the lease
9387
+ // GUARANTEED to remain is `recoveryWindowMs - lockExtendIntervalMs`
9388
+ // (e.g. 300s window, 100s interval ⇒ 200s left, not 300s). Budgeting
9389
+ // against that worst-case remaining lease — not the full window —
9390
+ // keeps the git ops inside the lock even across a stale heartbeat
9391
+ // (thread 4678). Pass a MINIMUM of 1 (not 0): finalizeGit treats
9392
+ // budgetMs 0 as "no budget" and restores the full per-op 120s
9393
+ // timeouts, so a window too small to budget (e.g. recoveryWindowMs
9394
+ // equal to lockExtendIntervalMs ⇒ difference 0) must still enforce a
9395
+ // near-zero deadline that fails the git ops FAST rather than defeat
9396
+ // the lease-safety cap (suppressed advisory 8877).
9397
+ budgetMs: Math.max(1, recoveryWindowMs - lockExtendIntervalMs),
9398
+ // Provisioned job: a null baseCloneSha means no genuine clone-time
9399
+ // base snapshot exists, so finalizeGit must NOT fall back to a live
9400
+ // (post-harness) read for its staleness count (advisory 4764).
9401
+ provisioned: true,
8354
9402
  });
8355
9403
  } catch (err) {
8356
9404
  gitResult = { remote: provisioned.remote, branch: provisioned.workingBranch, baseSha: provisioned.startSha || null, commits: [], pushed: false, error: redactToken(err.message, repoToken) };
9405
+ // #229: a finalizeGit throw is an ERROR outcome, not a clean end.
9406
+ // Keep runCompleted false so the relay close reason is 'error' (not
9407
+ // 'normal'), and surface the redacted cause with correlation keys —
9408
+ // this catch would otherwise be the only silent git-failure path.
9409
+ gitFinalizeFailed = true;
9410
+ logger.error(`[${jobType}] job ${job.jobKey} git finalize threw (${aiCorr}) — ${oneLineLog(gitResult.error)}`);
8357
9411
  }
8358
9412
  } else if (provisioned) {
8359
9413
  gitResult = { remote: provisioned.remote, branch: provisioned.workingBranch, baseSha: provisioned.startSha || null, commits: [], pushed: false };
8360
9414
  }
9415
+ // Reached the end of the run + finalization without throwing. A git
9416
+ // finalization error keeps this false so the relay close is 'error'.
9417
+ runCompleted = !gitFinalizeFailed;
8361
9418
  } finally {
8362
9419
  if (isContainer) liveRunIds.delete(runId);
8363
9420
  // #205: clear the in-flight job marker now the harness has stopped — the
8364
9421
  // owning lifecycle's finally is the authoritative "job finished" signal,
8365
9422
  // so this namespace no longer holds a surviving harness for this job.
8366
9423
  removeJobMarker(workerNsDir, job.jobKey);
8367
- if (runDir && !keepRuns) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } }
9424
+ // Preserve the workspace when a push FAILED even under the default
9425
+ // --keep-runs=false (issue #231): a rejected push strands the new commits
9426
+ // in this throwaway clone's object database, so `strandedCommits` is only a
9427
+ // recovery HANDLE if the objects still exist. Keep the run dir (still
9428
+ // age-gated by the reaper, so it is a recovery window, not a leak) and log
9429
+ // its path so an operator can recover the SHAs the error line named.
9430
+ const preserveForRecovery = shouldPreserveRunDir(gitResult);
9431
+ // Correlation handle (issue #231 observability): join these push-failure
9432
+ // diagnostics to the AgentInstance / relay timelines even with concurrent
9433
+ // jobs — jobKey alone is not enough, so carry the process + element keys.
9434
+ const corr = `instance ${job.processInstanceKey ?? '-'}/elem ${job.elementInstanceKey ?? '-'}`;
9435
+ if (runDir && !keepRuns && !preserveForRecovery) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } }
9436
+ else if (runDir && preserveForRecovery) {
9437
+ // Refresh the run dir's mtime so the age-gated reaper grants it a FULL
9438
+ // recovery window from NOW. The reaper deletes `run-*`/`res-*` dirs whose
9439
+ // mtime is older than maxAgeMs (default 1h); a long-running (multi-hour)
9440
+ // job's dir is already aged when the push fails, so without this touch the
9441
+ // "preserved" recovery workspace would be reaped on the very next sweep,
9442
+ // defeating the preservation (thread 8905). Also refresh the ENCLOSING
9443
+ // worker namespace dir: the cross-process reclaimer (`reclaimOrphanNamespaces`)
9444
+ // age-gates on the NAMESPACE dir's mtime, NOT the child run dir's, so once
9445
+ // this worker exits (owner proven dead) a sibling would otherwise see the
9446
+ // already-aged namespace as old and delete it WHOLE — taking the just-preserved
9447
+ // recovery workspace with it (thread 9056). Best-effort — a failed touch
9448
+ // must never mask the recovery log below.
9449
+ try { const t = new Date(); utimesSync(runDir, t, t); if (workerNsDir) utimesSync(workerNsDir, t, t); } catch { /* best effort */ }
9450
+ // Label the stranded location accurately: an `offBranch` mismatch means
9451
+ // HEAD is BACK on the work branch but commits were left on ANOTHER local
9452
+ // branch, so "HEAD left the work branch" would be wrong for it; a moved
9453
+ // mismatch means HEAD itself left (advisory 8907).
9454
+ const bm = gitResult?.branchMismatch;
9455
+ const reason = bm
9456
+ ? (bm.offBranch
9457
+ ? 'push refused — commits left on another local branch'
9458
+ : `push refused — HEAD left the work branch (now on '${bm.actual || 'detached HEAD'}')`)
9459
+ : 'push failed';
9460
+ logger.error(`[${jobType}] job ${job.jobKey} (${corr}): preserving workspace '${runDir}' (${reason}) so the stranded commit(s) remain recoverable — copy them out before the reaper ages it away`);
9461
+ }
8368
9462
  if (runDir) liveRunDirs.delete(runDir);
8369
9463
  // Emit the relay session's `phase:close` lifecycle event and drain its
8370
9464
  // outbound buffer before the job settles (so the live-terminal tail is
8371
9465
  // flushed, nanobpm/nano-workforce#710), then detach its inbound-frame
8372
9466
  // subscription so it never outlives the job or leaks a steer listener
8373
9467
  // across jobs. Bounded internally — a hub outage never wedges completion.
8374
- if (relaySession) { try { await relaySession.close(); } catch { /* best effort */ } }
9468
+ // #229: pass a close reason (normal / job-killed / error) so the relay's
9469
+ // close log distinguishes a clean end from a killed/errored run. The error
9470
+ // reason is keyed off `pushFailed` (not `pushError`) so a branch-mismatch /
9471
+ // incomplete-scan finalize — which strands work + preserves the workspace
9472
+ // but attempts NO push — still closes as `error`. See computeRelayCloseReason.
9473
+ if (relaySession) {
9474
+ const relayCloseReason = computeRelayCloseReason(result, runCompleted, gitResult);
9475
+ try { await relaySession.close(relayCloseReason); } catch { /* best effort */ }
9476
+ }
8375
9477
  }
8376
9478
 
8377
9479
  // Read the agent's structured result: the file it wrote, else a stdout
@@ -8386,10 +9488,60 @@ async function workAgent(req, flags) {
8386
9488
  const resultEnvelope = buildResultEnvelope(result, { sandbox, image, git: gitResult, result: rawResult, promptResourceKey });
8387
9489
  if (result.ok) {
8388
9490
  const gitNote = gitResult
8389
- ? ` [${gitResult.branch ? `branch ${gitResult.branch}` : 'detached HEAD'}: ${gitResult.commits.length} commit(s), ${gitResult.branch ? (gitResult.pushed ? 'pushed' : (gitResult.pushError ? 'push FAILED' : 'not pushed')) : 'no branch to push'}${gitResult.pr?.found ? `, PR #${gitResult.pr.number}` : ''}]`
9491
+ ? ` [${gitResult.branch ? `branch ${gitResult.branch}` : 'detached HEAD'}: ${gitResult.commits.length} commit(s), ${gitResult.branch ? (gitResult.pushed ? 'pushed' : (gitResult.pushFailed ? 'push FAILED' : 'not pushed')) : 'no branch to push'}${gitResult.pr?.found ? `, PR #${gitResult.pr.number}` : ''}]`
8390
9492
  : '';
8391
9493
  logger.info(`[${jobType}] job ${job.jobKey} complete (exit 0)${result.truncated ? ' [output truncated]' : ''}${gitNote}`);
8392
- if (gitResult?.pushError) logger.warn(`[${jobType}] job ${job.jobKey}: branch push failed — ${gitResult.pushError}`);
9494
+ if (gitResult?.pushFailed) {
9495
+ // Elevate a push failure from a terse info tail to a loud, actionable
9496
+ // error naming the stranded commit SHAs (issue #231): these commits are
9497
+ // unpushed in a throwaway workspace and will be lost with no PR unless
9498
+ // recovered from these SHAs. Key off `pushFailed`, not `pushError`, so
9499
+ // the branch-mismatch strand (HEAD moved off the work branch — no push
9500
+ // attempted, so no pushError) is reported too; use its `branchMismatch`
9501
+ // as the failure detail when there is no push error string.
9502
+ const stranded = gitResult.strandedCommits && gitResult.strandedCommits.length ? gitResult.strandedCommits : (gitResult.commits || []);
9503
+ // Label the stranded commits with the ref they ACTUALLY sit on. For a
9504
+ // branch mismatch where HEAD moved that is `branchMismatch.actual` (the
9505
+ // ref the harness moved HEAD to, or '(detached)'); for the off-branch
9506
+ // case (HEAD returned to the work branch, `offBranch` set, actual null)
9507
+ // the commits sit on ANOTHER local branch — render neutral wording, NOT
9508
+ // '(detached)', which would misdescribe them (suppressed advisory 8746).
9509
+ // Fall back to the work branch for a plain (non-mismatch) push failure.
9510
+ const strandedRef = gitResult.branchMismatch
9511
+ ? (gitResult.branchMismatch.actual
9512
+ || (gitResult.branchMismatch.offBranch ? '(another local branch)' : '(detached)'))
9513
+ : gitResult.branch;
9514
+ // A branch-mismatch strand is a UNION of commits on two different refs:
9515
+ // the ones the harness left off the work branch (on `strandedRef`) AND
9516
+ // the work-branch commits we refused to push (`strandedOnBranch`). Naming
9517
+ // a single ref for the whole set would misdescribe the work-branch
9518
+ // portion, so when the strand is genuinely MIXED attribute each group to
9519
+ // its own ref; otherwise keep the precise single-ref wording (suppressed
9520
+ // advisory 8882).
9521
+ const onBranchCount = gitResult.branchMismatch?.strandedOnBranch || 0;
9522
+ let shaNote = '';
9523
+ if (stranded.length) {
9524
+ if (gitResult.branchMismatch && onBranchCount > 0 && onBranchCount < stranded.length) {
9525
+ const elsewhereCount = stranded.length - onBranchCount;
9526
+ shaNote = ` — ${stranded.length} commit(s) are UNPUSHED and will be lost, no PR (${onBranchCount} on work branch '${gitResult.branchMismatch.expected}', ${elsewhereCount} on '${strandedRef}') [stranded: ${stranded.join(', ')}]`;
9527
+ } else {
9528
+ // Whole strand sits on one ref: the work branch itself when every
9529
+ // stranded commit is a work-branch commit, else `strandedRef`.
9530
+ const soleRef = (gitResult.branchMismatch && onBranchCount >= stranded.length)
9531
+ ? gitResult.branchMismatch.expected
9532
+ : strandedRef;
9533
+ shaNote = ` — ${stranded.length} commit(s) on branch '${soleRef}' are UNPUSHED and will be lost, no PR [stranded: ${stranded.join(', ')}]`;
9534
+ }
9535
+ }
9536
+ const corr = `instance ${job.processInstanceKey ?? '-'}/elem ${job.elementInstanceKey ?? '-'}`;
9537
+ const failDetail = gitResult.pushError
9538
+ || (gitResult.branchMismatch
9539
+ ? (gitResult.branchMismatch.offBranch
9540
+ ? `commits were left on another local branch (HEAD returned to work branch '${gitResult.branchMismatch.expected}') — refused to push stale work`
9541
+ : `HEAD moved to '${gitResult.branchMismatch.actual || '(detached)'}' off work branch '${gitResult.branchMismatch.expected}' — refused to push stale work`)
9542
+ : 'push not attempted');
9543
+ logger.error(`[${jobType}] job ${job.jobKey} (${corr}): branch push FAILED${shaNote} — ${failDetail}`);
9544
+ }
8393
9545
  // Guard the operator against silent empty escalations: a success that
8394
9546
  // yields no *effective* result vars (no file/sentinel at all, an empty
8395
9547
  // `{}`, or only reserved keys that were sanitized away) means the
@@ -8502,11 +9654,15 @@ async function workAgent(req, flags) {
8502
9654
  // relay session so `runAgentJob` consumes it unchanged. Transcript rides the
8503
9655
  // one connection keyed by this instance + jobKey; inbound steer is fanned
8504
9656
  // back to this job's PTY by the runtime's per-instance steer router.
8505
- relaySessionFor: (jobKey) => {
9657
+ relaySessionFor: (jobKey, extra = {}) => {
8506
9658
  try {
8507
9659
  return createHostRelaySession({
8508
9660
  instance: workerName,
8509
9661
  jobKey,
9662
+ // #229: correlation join keys + lazy AgentInstance-key resolver.
9663
+ elementInstanceKey: extra.elementInstanceKey,
9664
+ processInstanceKey: extra.processInstanceKey,
9665
+ agentInstanceKey: extra.agentInstanceKey,
8510
9666
  publish: (text) => {
8511
9667
  // Fire-and-forget over the live handle; a frame between a drop and
8512
9668
  // the next reconnect is a harmless no-op (best-effort semantics).
@@ -13888,6 +15044,10 @@ export {
13888
15044
  ensureAcpFlag,
13889
15045
  provisionRepo,
13890
15046
  finalizeGit,
15047
+ runGit,
15048
+ sanitizeBranchSegment,
15049
+ shouldPreserveRunDir,
15050
+ computeRelayCloseReason,
13891
15051
  describeGitFailure,
13892
15052
  boundGitOutput,
13893
15053
  reconcileAgentPr,