c8ctl-plugin-nano 1.61.0 → 1.61.2
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/README.md +42 -6
- package/agent-instance.mjs +1187 -107
- package/c8ctl-plugin.js +1200 -38
- package/package.json +8 -8
- package/work-relay.mjs +73 -5
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 =
|
|
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 =
|
|
4201
|
-
const co =
|
|
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 =
|
|
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
|
-
|
|
4264
|
-
|
|
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
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
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
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) &&
|
|
4438
|
-
|
|
4439
|
-
|
|
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
|
|
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
|
-
|
|
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,33 @@ 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 {
|
|
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 if (agentInstanceProducer.retryPending) {
|
|
9086
|
+
logger.info(`[${jobType}] AgentInstance producer not yet active for external agent job (${aiCorr}) — the initial createAgentInstance did not mint a key in time (transient rejection, no-key response, or bounded-out create), but the create stays armed to retry on the ACP hot path, so a run that recovers can still record a durable transcript (job completion unaffected).`);
|
|
9087
|
+
} else {
|
|
9088
|
+
logger.info(`[${jobType}] AgentInstance producer unavailable for external agent job (${aiCorr}) — activation not attempted: the host SDK lacks createAgentInstance/updateAgentInstance or the ACP classifier is unavailable; continuing without a durable transcript (job completion unaffected).`);
|
|
9089
|
+
}
|
|
9090
|
+
} catch (err) {
|
|
9091
|
+
logger.warn(`[${jobType}] AgentInstance producer activate() threw (${aiCorr}) — ${oneLineLog(err?.message || err)}; continuing without a durable transcript (job completion unaffected).`);
|
|
9092
|
+
}
|
|
9093
|
+
} else {
|
|
9094
|
+
logger.debug?.(`[${jobType}] AgentInstance producer skipped (${aiCorr}) — ${agentInstanceOff ? 'NANO_AGENT_INSTANCE=off' : 'not an external agent job (no lease token / elementInstanceKey)'}.`);
|
|
8103
9095
|
}
|
|
8104
9096
|
|
|
8105
9097
|
// Fail-closed on a half-specified repository envelope (issue #129,
|
|
@@ -8145,9 +9137,9 @@ async function workAgent(req, flags) {
|
|
|
8145
9137
|
mkdirSync(workerNsDir, { recursive: true });
|
|
8146
9138
|
runDir = mkdtempSync(join(workerNsDir, 'run-'));
|
|
8147
9139
|
liveRunDirs.add(runDir);
|
|
8148
|
-
provisioned = provisionRepo({ envelope, token: repoToken, runDir, timeoutMs: cloneTimeoutMs });
|
|
9140
|
+
provisioned = provisionRepo({ envelope, token: repoToken, runDir, runId, timeoutMs: cloneTimeoutMs, logger, corr: aiCorr });
|
|
8149
9141
|
if (provisioned.baseFetchError) {
|
|
8150
|
-
logger.warn(`[${jobType}] job ${job.jobKey} base fetch failed — ${provisioned.baseFetchError}; base...head diffs may be unavailable`);
|
|
9142
|
+
logger.warn(`[${jobType}] job ${job.jobKey} base fetch failed (${aiCorr}) — ${oneLineLog(provisioned.baseFetchError)}; base...head diffs may be unavailable`);
|
|
8151
9143
|
}
|
|
8152
9144
|
cwd = provisioned.workspaceDir;
|
|
8153
9145
|
extraEnv = {
|
|
@@ -8174,7 +9166,13 @@ async function workAgent(req, flags) {
|
|
|
8174
9166
|
if (isContainer) liveRunIds.delete(runId);
|
|
8175
9167
|
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
8176
9168
|
const msg = err instanceof ProvisionError ? err.message : `provisioning error: ${err.message}`;
|
|
8177
|
-
|
|
9169
|
+
// #229: include the correlation keys — a clone/checkout failure occurs
|
|
9170
|
+
// before provisionRepo reaches either branch-decision log, so this is the
|
|
9171
|
+
// ONLY git-provisioning signal and must be joinable to the other channels.
|
|
9172
|
+
// Normalize ONLY the logged value to one line (git stderr/stdout preserved
|
|
9173
|
+
// by gitErrorDetail is multiline) so continuation lines can't split this
|
|
9174
|
+
// correlated record; the settlement error below keeps the full detail.
|
|
9175
|
+
logger.warn(`[${jobType}] job ${job.jobKey} not provisioned (${aiCorr}) — ${oneLineLog(msg)}; retries left ${retries}`);
|
|
8178
9176
|
return settleJob.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
|
|
8179
9177
|
}
|
|
8180
9178
|
} else if (!isContainer) {
|
|
@@ -8207,6 +9205,16 @@ async function workAgent(req, flags) {
|
|
|
8207
9205
|
|
|
8208
9206
|
let result;
|
|
8209
9207
|
let gitResult = null;
|
|
9208
|
+
// #229: a finalizeGit throw is an ERROR outcome even though `result.ok` was
|
|
9209
|
+
// true — track it so the relay close reason below is 'error', not 'normal'.
|
|
9210
|
+
let gitFinalizeFailed = false;
|
|
9211
|
+
// #229: track whether the run + finalization actually ran to completion. The
|
|
9212
|
+
// relay close reason must NOT be inferred from `result` alone: if
|
|
9213
|
+
// runAgentJob (or any later step) throws before `result` is assigned, the
|
|
9214
|
+
// finally runs with `result === undefined` and would mislabel an ERRORED run
|
|
9215
|
+
// as a `normal` close. This flag flips true only once we reach the end of the
|
|
9216
|
+
// try body, so an early throw is correctly reported as `error`.
|
|
9217
|
+
let runCompleted = false;
|
|
8210
9218
|
// The per-job live-terminal relay session (issue #173): streams this job's
|
|
8211
9219
|
// harness terminal over the single-owner supervisor's ONE multiplexed host
|
|
8212
9220
|
// connection, keyed by this worker's instance + the jobKey, and accepts
|
|
@@ -8215,7 +9223,14 @@ async function workAgent(req, flags) {
|
|
|
8215
9223
|
// in the finally so its steer subscription never leaks across jobs.
|
|
8216
9224
|
let relaySession = null;
|
|
8217
9225
|
if (agenticPlane) {
|
|
8218
|
-
|
|
9226
|
+
// #229: thread the correlation join keys + a lazy AgentInstance-key getter
|
|
9227
|
+
// so the relay's open/close/first-update logs can be reconciled against the
|
|
9228
|
+
// engine (AgentInstance), job and git channels.
|
|
9229
|
+
relaySession = agenticPlane.relaySessionFor(job.jobKey, {
|
|
9230
|
+
elementInstanceKey: job.elementInstanceKey,
|
|
9231
|
+
processInstanceKey: job.processInstanceKey,
|
|
9232
|
+
agentInstanceKey: () => agentInstanceProducer?.agentInstanceKey,
|
|
9233
|
+
});
|
|
8219
9234
|
}
|
|
8220
9235
|
// Private structured-result channel: hand the agent a file (outside any
|
|
8221
9236
|
// repo clone so it can't be `git add`ed) to write its job-result vars to.
|
|
@@ -8337,7 +9352,7 @@ async function workAgent(req, flags) {
|
|
|
8337
9352
|
// job end (a failed run leaves it non-terminal so a retry/reactivation
|
|
8338
9353
|
// continues the same instance). Best-effort — never disturbs job settlement.
|
|
8339
9354
|
if (agentInstanceProducer) {
|
|
8340
|
-
try { await agentInstanceProducer.complete(result.ok); } catch {
|
|
9355
|
+
try { await agentInstanceProducer.complete(result.ok); } catch (err) { logger.warn(`[${jobType}] AgentInstance producer complete() threw (${aiCorr}) — ${oneLineLog(err?.message || err)}; job settlement unaffected.`); }
|
|
8341
9356
|
}
|
|
8342
9357
|
|
|
8343
9358
|
// Finalize git only when the harness succeeded — never push a
|
|
@@ -8349,29 +9364,118 @@ async function workAgent(req, flags) {
|
|
|
8349
9364
|
gitEnv: provisioned.gitEnv,
|
|
8350
9365
|
startSha: provisioned.startSha,
|
|
8351
9366
|
workingBranch: provisioned.workingBranch,
|
|
9367
|
+
baseBranch: provisioned.baseBranch,
|
|
9368
|
+
baseCloneSha: provisioned.baseCloneSha,
|
|
9369
|
+
baseBaselineUnknown: provisioned.baseBaselineUnknown,
|
|
9370
|
+
hasPrBranch: provisioned.hasPrBranch,
|
|
8352
9371
|
envelope,
|
|
8353
9372
|
token: repoToken,
|
|
9373
|
+
logger,
|
|
9374
|
+
corr: aiCorr,
|
|
9375
|
+
keepRuns,
|
|
9376
|
+
// Cap finalization's cumulative network-git wall-time below the
|
|
9377
|
+
// worker's activation-lock/recovery window so its event-loop-blocking
|
|
9378
|
+
// spawnSync ops cannot outlast the lease and let this job be
|
|
9379
|
+
// reactivated mid-finalization (thread 4754). Use the WORKER-level
|
|
9380
|
+
// `recoveryWindowMs`, NOT the per-task `effectiveRecoveryWindowMs`: a
|
|
9381
|
+
// task override widens only the harness liveness window, not the
|
|
9382
|
+
// broker lock (see #172 note above + dispatch config below), so
|
|
9383
|
+
// budgeting against the widened value could still let the git ops run
|
|
9384
|
+
// past the real lease (thread 8811). SUBTRACT `lockExtendIntervalMs`:
|
|
9385
|
+
// the lock is renewed to a full `recoveryWindowMs` only at each
|
|
9386
|
+
// heartbeat, and the heartbeat fiber cannot fire while finalization's
|
|
9387
|
+
// spawnSync ops block the event loop. At finalization start the last
|
|
9388
|
+
// beat could have fired up to `lockExtendIntervalMs` ago, so the lease
|
|
9389
|
+
// GUARANTEED to remain is `recoveryWindowMs - lockExtendIntervalMs`
|
|
9390
|
+
// (e.g. 300s window, 100s interval ⇒ 200s left, not 300s). Budgeting
|
|
9391
|
+
// against that worst-case remaining lease — not the full window —
|
|
9392
|
+
// keeps the git ops inside the lock even across a stale heartbeat
|
|
9393
|
+
// (thread 4678). Pass a MINIMUM of 1 (not 0): finalizeGit treats
|
|
9394
|
+
// budgetMs 0 as "no budget" and restores the full per-op 120s
|
|
9395
|
+
// timeouts, so a window too small to budget (e.g. recoveryWindowMs
|
|
9396
|
+
// equal to lockExtendIntervalMs ⇒ difference 0) must still enforce a
|
|
9397
|
+
// near-zero deadline that fails the git ops FAST rather than defeat
|
|
9398
|
+
// the lease-safety cap (suppressed advisory 8877).
|
|
9399
|
+
budgetMs: Math.max(1, recoveryWindowMs - lockExtendIntervalMs),
|
|
9400
|
+
// Provisioned job: a null baseCloneSha means no genuine clone-time
|
|
9401
|
+
// base snapshot exists, so finalizeGit must NOT fall back to a live
|
|
9402
|
+
// (post-harness) read for its staleness count (advisory 4764).
|
|
9403
|
+
provisioned: true,
|
|
8354
9404
|
});
|
|
8355
9405
|
} catch (err) {
|
|
8356
9406
|
gitResult = { remote: provisioned.remote, branch: provisioned.workingBranch, baseSha: provisioned.startSha || null, commits: [], pushed: false, error: redactToken(err.message, repoToken) };
|
|
9407
|
+
// #229: a finalizeGit throw is an ERROR outcome, not a clean end.
|
|
9408
|
+
// Keep runCompleted false so the relay close reason is 'error' (not
|
|
9409
|
+
// 'normal'), and surface the redacted cause with correlation keys —
|
|
9410
|
+
// this catch would otherwise be the only silent git-failure path.
|
|
9411
|
+
gitFinalizeFailed = true;
|
|
9412
|
+
logger.error(`[${jobType}] job ${job.jobKey} git finalize threw (${aiCorr}) — ${oneLineLog(gitResult.error)}`);
|
|
8357
9413
|
}
|
|
8358
9414
|
} else if (provisioned) {
|
|
8359
9415
|
gitResult = { remote: provisioned.remote, branch: provisioned.workingBranch, baseSha: provisioned.startSha || null, commits: [], pushed: false };
|
|
8360
9416
|
}
|
|
9417
|
+
// Reached the end of the run + finalization without throwing. A git
|
|
9418
|
+
// finalization error keeps this false so the relay close is 'error'.
|
|
9419
|
+
runCompleted = !gitFinalizeFailed;
|
|
8361
9420
|
} finally {
|
|
8362
9421
|
if (isContainer) liveRunIds.delete(runId);
|
|
8363
9422
|
// #205: clear the in-flight job marker now the harness has stopped — the
|
|
8364
9423
|
// owning lifecycle's finally is the authoritative "job finished" signal,
|
|
8365
9424
|
// so this namespace no longer holds a surviving harness for this job.
|
|
8366
9425
|
removeJobMarker(workerNsDir, job.jobKey);
|
|
8367
|
-
|
|
9426
|
+
// Preserve the workspace when a push FAILED even under the default
|
|
9427
|
+
// --keep-runs=false (issue #231): a rejected push strands the new commits
|
|
9428
|
+
// in this throwaway clone's object database, so `strandedCommits` is only a
|
|
9429
|
+
// recovery HANDLE if the objects still exist. Keep the run dir (still
|
|
9430
|
+
// age-gated by the reaper, so it is a recovery window, not a leak) and log
|
|
9431
|
+
// its path so an operator can recover the SHAs the error line named.
|
|
9432
|
+
const preserveForRecovery = shouldPreserveRunDir(gitResult);
|
|
9433
|
+
// Correlation handle (issue #231 observability): join these push-failure
|
|
9434
|
+
// diagnostics to the AgentInstance / relay timelines even with concurrent
|
|
9435
|
+
// jobs — jobKey alone is not enough, so carry the process + element keys.
|
|
9436
|
+
const corr = `instance ${job.processInstanceKey ?? '-'}/elem ${job.elementInstanceKey ?? '-'}`;
|
|
9437
|
+
if (runDir && !keepRuns && !preserveForRecovery) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
9438
|
+
else if (runDir && preserveForRecovery) {
|
|
9439
|
+
// Refresh the run dir's mtime so the age-gated reaper grants it a FULL
|
|
9440
|
+
// recovery window from NOW. The reaper deletes `run-*`/`res-*` dirs whose
|
|
9441
|
+
// mtime is older than maxAgeMs (default 1h); a long-running (multi-hour)
|
|
9442
|
+
// job's dir is already aged when the push fails, so without this touch the
|
|
9443
|
+
// "preserved" recovery workspace would be reaped on the very next sweep,
|
|
9444
|
+
// defeating the preservation (thread 8905). Also refresh the ENCLOSING
|
|
9445
|
+
// worker namespace dir: the cross-process reclaimer (`reclaimOrphanNamespaces`)
|
|
9446
|
+
// age-gates on the NAMESPACE dir's mtime, NOT the child run dir's, so once
|
|
9447
|
+
// this worker exits (owner proven dead) a sibling would otherwise see the
|
|
9448
|
+
// already-aged namespace as old and delete it WHOLE — taking the just-preserved
|
|
9449
|
+
// recovery workspace with it (thread 9056). Best-effort — a failed touch
|
|
9450
|
+
// must never mask the recovery log below.
|
|
9451
|
+
try { const t = new Date(); utimesSync(runDir, t, t); if (workerNsDir) utimesSync(workerNsDir, t, t); } catch { /* best effort */ }
|
|
9452
|
+
// Label the stranded location accurately: an `offBranch` mismatch means
|
|
9453
|
+
// HEAD is BACK on the work branch but commits were left on ANOTHER local
|
|
9454
|
+
// branch, so "HEAD left the work branch" would be wrong for it; a moved
|
|
9455
|
+
// mismatch means HEAD itself left (advisory 8907).
|
|
9456
|
+
const bm = gitResult?.branchMismatch;
|
|
9457
|
+
const reason = bm
|
|
9458
|
+
? (bm.offBranch
|
|
9459
|
+
? 'push refused — commits left on another local branch'
|
|
9460
|
+
: `push refused — HEAD left the work branch (now on '${bm.actual || 'detached HEAD'}')`)
|
|
9461
|
+
: 'push failed';
|
|
9462
|
+
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`);
|
|
9463
|
+
}
|
|
8368
9464
|
if (runDir) liveRunDirs.delete(runDir);
|
|
8369
9465
|
// Emit the relay session's `phase:close` lifecycle event and drain its
|
|
8370
9466
|
// outbound buffer before the job settles (so the live-terminal tail is
|
|
8371
9467
|
// flushed, nanobpm/nano-workforce#710), then detach its inbound-frame
|
|
8372
9468
|
// subscription so it never outlives the job or leaks a steer listener
|
|
8373
9469
|
// across jobs. Bounded internally — a hub outage never wedges completion.
|
|
8374
|
-
|
|
9470
|
+
// #229: pass a close reason (normal / job-killed / error) so the relay's
|
|
9471
|
+
// close log distinguishes a clean end from a killed/errored run. The error
|
|
9472
|
+
// reason is keyed off `pushFailed` (not `pushError`) so a branch-mismatch /
|
|
9473
|
+
// incomplete-scan finalize — which strands work + preserves the workspace
|
|
9474
|
+
// but attempts NO push — still closes as `error`. See computeRelayCloseReason.
|
|
9475
|
+
if (relaySession) {
|
|
9476
|
+
const relayCloseReason = computeRelayCloseReason(result, runCompleted, gitResult);
|
|
9477
|
+
try { await relaySession.close(relayCloseReason); } catch { /* best effort */ }
|
|
9478
|
+
}
|
|
8375
9479
|
}
|
|
8376
9480
|
|
|
8377
9481
|
// Read the agent's structured result: the file it wrote, else a stdout
|
|
@@ -8386,10 +9490,60 @@ async function workAgent(req, flags) {
|
|
|
8386
9490
|
const resultEnvelope = buildResultEnvelope(result, { sandbox, image, git: gitResult, result: rawResult, promptResourceKey });
|
|
8387
9491
|
if (result.ok) {
|
|
8388
9492
|
const gitNote = gitResult
|
|
8389
|
-
? ` [${gitResult.branch ? `branch ${gitResult.branch}` : 'detached HEAD'}: ${gitResult.commits.length} commit(s), ${gitResult.branch ? (gitResult.pushed ? 'pushed' : (gitResult.
|
|
9493
|
+
? ` [${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
9494
|
: '';
|
|
8391
9495
|
logger.info(`[${jobType}] job ${job.jobKey} complete (exit 0)${result.truncated ? ' [output truncated]' : ''}${gitNote}`);
|
|
8392
|
-
if (gitResult?.
|
|
9496
|
+
if (gitResult?.pushFailed) {
|
|
9497
|
+
// Elevate a push failure from a terse info tail to a loud, actionable
|
|
9498
|
+
// error naming the stranded commit SHAs (issue #231): these commits are
|
|
9499
|
+
// unpushed in a throwaway workspace and will be lost with no PR unless
|
|
9500
|
+
// recovered from these SHAs. Key off `pushFailed`, not `pushError`, so
|
|
9501
|
+
// the branch-mismatch strand (HEAD moved off the work branch — no push
|
|
9502
|
+
// attempted, so no pushError) is reported too; use its `branchMismatch`
|
|
9503
|
+
// as the failure detail when there is no push error string.
|
|
9504
|
+
const stranded = gitResult.strandedCommits && gitResult.strandedCommits.length ? gitResult.strandedCommits : (gitResult.commits || []);
|
|
9505
|
+
// Label the stranded commits with the ref they ACTUALLY sit on. For a
|
|
9506
|
+
// branch mismatch where HEAD moved that is `branchMismatch.actual` (the
|
|
9507
|
+
// ref the harness moved HEAD to, or '(detached)'); for the off-branch
|
|
9508
|
+
// case (HEAD returned to the work branch, `offBranch` set, actual null)
|
|
9509
|
+
// the commits sit on ANOTHER local branch — render neutral wording, NOT
|
|
9510
|
+
// '(detached)', which would misdescribe them (suppressed advisory 8746).
|
|
9511
|
+
// Fall back to the work branch for a plain (non-mismatch) push failure.
|
|
9512
|
+
const strandedRef = gitResult.branchMismatch
|
|
9513
|
+
? (gitResult.branchMismatch.actual
|
|
9514
|
+
|| (gitResult.branchMismatch.offBranch ? '(another local branch)' : '(detached)'))
|
|
9515
|
+
: gitResult.branch;
|
|
9516
|
+
// A branch-mismatch strand is a UNION of commits on two different refs:
|
|
9517
|
+
// the ones the harness left off the work branch (on `strandedRef`) AND
|
|
9518
|
+
// the work-branch commits we refused to push (`strandedOnBranch`). Naming
|
|
9519
|
+
// a single ref for the whole set would misdescribe the work-branch
|
|
9520
|
+
// portion, so when the strand is genuinely MIXED attribute each group to
|
|
9521
|
+
// its own ref; otherwise keep the precise single-ref wording (suppressed
|
|
9522
|
+
// advisory 8882).
|
|
9523
|
+
const onBranchCount = gitResult.branchMismatch?.strandedOnBranch || 0;
|
|
9524
|
+
let shaNote = '';
|
|
9525
|
+
if (stranded.length) {
|
|
9526
|
+
if (gitResult.branchMismatch && onBranchCount > 0 && onBranchCount < stranded.length) {
|
|
9527
|
+
const elsewhereCount = stranded.length - onBranchCount;
|
|
9528
|
+
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(', ')}]`;
|
|
9529
|
+
} else {
|
|
9530
|
+
// Whole strand sits on one ref: the work branch itself when every
|
|
9531
|
+
// stranded commit is a work-branch commit, else `strandedRef`.
|
|
9532
|
+
const soleRef = (gitResult.branchMismatch && onBranchCount >= stranded.length)
|
|
9533
|
+
? gitResult.branchMismatch.expected
|
|
9534
|
+
: strandedRef;
|
|
9535
|
+
shaNote = ` — ${stranded.length} commit(s) on branch '${soleRef}' are UNPUSHED and will be lost, no PR [stranded: ${stranded.join(', ')}]`;
|
|
9536
|
+
}
|
|
9537
|
+
}
|
|
9538
|
+
const corr = `instance ${job.processInstanceKey ?? '-'}/elem ${job.elementInstanceKey ?? '-'}`;
|
|
9539
|
+
const failDetail = gitResult.pushError
|
|
9540
|
+
|| (gitResult.branchMismatch
|
|
9541
|
+
? (gitResult.branchMismatch.offBranch
|
|
9542
|
+
? `commits were left on another local branch (HEAD returned to work branch '${gitResult.branchMismatch.expected}') — refused to push stale work`
|
|
9543
|
+
: `HEAD moved to '${gitResult.branchMismatch.actual || '(detached)'}' off work branch '${gitResult.branchMismatch.expected}' — refused to push stale work`)
|
|
9544
|
+
: 'push not attempted');
|
|
9545
|
+
logger.error(`[${jobType}] job ${job.jobKey} (${corr}): branch push FAILED${shaNote} — ${failDetail}`);
|
|
9546
|
+
}
|
|
8393
9547
|
// Guard the operator against silent empty escalations: a success that
|
|
8394
9548
|
// yields no *effective* result vars (no file/sentinel at all, an empty
|
|
8395
9549
|
// `{}`, or only reserved keys that were sanitized away) means the
|
|
@@ -8502,11 +9656,15 @@ async function workAgent(req, flags) {
|
|
|
8502
9656
|
// relay session so `runAgentJob` consumes it unchanged. Transcript rides the
|
|
8503
9657
|
// one connection keyed by this instance + jobKey; inbound steer is fanned
|
|
8504
9658
|
// back to this job's PTY by the runtime's per-instance steer router.
|
|
8505
|
-
relaySessionFor: (jobKey) => {
|
|
9659
|
+
relaySessionFor: (jobKey, extra = {}) => {
|
|
8506
9660
|
try {
|
|
8507
9661
|
return createHostRelaySession({
|
|
8508
9662
|
instance: workerName,
|
|
8509
9663
|
jobKey,
|
|
9664
|
+
// #229: correlation join keys + lazy AgentInstance-key resolver.
|
|
9665
|
+
elementInstanceKey: extra.elementInstanceKey,
|
|
9666
|
+
processInstanceKey: extra.processInstanceKey,
|
|
9667
|
+
agentInstanceKey: extra.agentInstanceKey,
|
|
8510
9668
|
publish: (text) => {
|
|
8511
9669
|
// Fire-and-forget over the live handle; a frame between a drop and
|
|
8512
9670
|
// the next reconnect is a harmless no-op (best-effort semantics).
|
|
@@ -13888,6 +15046,10 @@ export {
|
|
|
13888
15046
|
ensureAcpFlag,
|
|
13889
15047
|
provisionRepo,
|
|
13890
15048
|
finalizeGit,
|
|
15049
|
+
runGit,
|
|
15050
|
+
sanitizeBranchSegment,
|
|
15051
|
+
shouldPreserveRunDir,
|
|
15052
|
+
computeRelayCloseReason,
|
|
13891
15053
|
describeGitFailure,
|
|
13892
15054
|
boundGitOutput,
|
|
13893
15055
|
reconcileAgentPr,
|