Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 177 additions & 48 deletions bin/multiagent-safety.js
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,9 @@ const DEPRECATED_COMMAND_ALIASES = new Map([
const AGENT_BOT_DESCRIPTIONS = [
['agents', 'Start/stop review + cleanup bots for this repo'],
];
const DOCTOR_AUTO_FINISH_DETAIL_LIMIT = 6;
const DOCTOR_AUTO_FINISH_BRANCH_LABEL_MAX = 72;
const DOCTOR_AUTO_FINISH_MESSAGE_MAX = 160;

function envFlagIsTruthy(raw) {
const lowered = String(raw || '').trim().toLowerCase();
Expand Down Expand Up @@ -504,6 +507,113 @@ function run(cmd, args, options = {}) {
});
}

function formatElapsedDuration(ms) {
const durationMs = Number.isFinite(ms) ? Math.max(0, ms) : 0;
if (durationMs < 1000) {
return `${Math.round(durationMs)}ms`;
}
if (durationMs < 10_000) {
return `${(durationMs / 1000).toFixed(1)}s`;
}
return `${Math.round(durationMs / 1000)}s`;
}

function truncateMiddle(value, maxLength) {
const text = String(value || '');
const limit = Number.isFinite(maxLength) ? Math.max(4, maxLength) : 0;
if (!limit || text.length <= limit) {
return text;
}

const visible = limit - 1;
const headLength = Math.ceil(visible / 2);
const tailLength = Math.floor(visible / 2);
return `${text.slice(0, headLength)}…${text.slice(text.length - tailLength)}`;
}

function truncateTail(value, maxLength) {
const text = String(value || '');
const limit = Number.isFinite(maxLength) ? Math.max(4, maxLength) : 0;
if (!limit || text.length <= limit) {
return text;
}
return `${text.slice(0, limit - 1)}…`;
}

function compactAutoFinishPathSegments(message) {
return String(message || '').replace(/\((\/[^)]+)\)/g, (_, rawPath) => {
if (
rawPath.includes(`${path.sep}.omx${path.sep}agent-worktrees${path.sep}`) ||
rawPath.includes(`${path.sep}.omc${path.sep}agent-worktrees${path.sep}`)
) {
return `(${path.basename(rawPath)})`;
}
return `(${truncateMiddle(rawPath, 72)})`;
});
}

function summarizeAutoFinishDetail(detail) {
const trimmed = String(detail || '').trim();
const match = trimmed.match(/^\[(\w+)\]\s+([^:]+):\s*(.*)$/);
if (!match) {
return truncateTail(compactAutoFinishPathSegments(trimmed), DOCTOR_AUTO_FINISH_MESSAGE_MAX);
}

const [, status, rawBranch, rawMessage] = match;
const branch = truncateMiddle(rawBranch, DOCTOR_AUTO_FINISH_BRANCH_LABEL_MAX);
let message = String(rawMessage || '').trim();

if (status === 'fail') {
message = message.replace(/^auto-finish failed\.?\s*/i, '');
if (/\[agent-sync-guard\]/.test(message) && /Resolve conflicts/i.test(message)) {
message = 'rebase conflict in finish flow; run rebase --continue or rebase --abort in the source-probe worktree';
} else if (/unable to compute ahead\/behind/i.test(message)) {
const aheadBehindMatch = message.match(/unable to compute ahead\/behind(?: \([^)]+\))?/i);
if (aheadBehindMatch) {
message = aheadBehindMatch[0];
}
} else if (/remote ref does not exist/i.test(message)) {
message = 'branch merged, but the remote ref was already removed during cleanup';
}
}

message = compactAutoFinishPathSegments(message)
.replace(/\s+\|\s+/g, '; ')
.trim();

return `[${status}] ${branch}: ${truncateTail(message, DOCTOR_AUTO_FINISH_MESSAGE_MAX)}`;
}

function printAutoFinishSummary(summary, options = {}) {
const enabled = Boolean(summary && summary.enabled);
const details = Array.isArray(summary && summary.details) ? summary.details : [];
const baseBranch = String(options.baseBranch || summary?.baseBranch || '').trim();
const verbose = Boolean(options.verbose);
const detailLimit = Number.isFinite(options.detailLimit)
? Math.max(0, options.detailLimit)
: DOCTOR_AUTO_FINISH_DETAIL_LIMIT;

if (enabled) {
console.log(
`[${TOOL_NAME}] Auto-finish sweep (base=${baseBranch}): attempted=${summary.attempted}, completed=${summary.completed}, skipped=${summary.skipped}, failed=${summary.failed}`,
);
const visibleDetails = verbose ? details : details.slice(0, detailLimit).map(summarizeAutoFinishDetail);
for (const detail of visibleDetails) {
console.log(`[${TOOL_NAME}] ${detail}`);
}
if (!verbose && details.length > detailLimit) {
console.log(
`[${TOOL_NAME}] … ${details.length - detailLimit} more branch result(s). Re-run with --verbose-auto-finish for full details.`,
);
}
return;
}

if (details.length > 0) {
console.log(`[${TOOL_NAME}] ${verbose ? details[0] : summarizeAutoFinishDetail(details[0])}`);
}
}

function gitRun(repoRoot, args, { allowFailure = false } = {}) {
const result = run('git', ['-C', repoRoot, ...args]);
if (!allowFailure && result.status !== 0) {
Expand Down Expand Up @@ -1121,7 +1231,7 @@ function parseSetupArgs(rawArgs, defaults) {
}

function parseDoctorArgs(rawArgs) {
return parseRepoTraversalArgs(rawArgs, {
const doctorDefaults = {
target: process.cwd(),
dropStaleLocks: true,
skipAgents: false,
Expand All @@ -1131,7 +1241,24 @@ function parseDoctorArgs(rawArgs) {
json: false,
allowProtectedBaseWrite: false,
waitForMerge: true,
});
verboseAutoFinish: false,
};
const forwardedArgs = [];

for (let index = 0; index < rawArgs.length; index += 1) {
const arg = rawArgs[index];
if (arg === '--verbose-auto-finish') {
doctorDefaults.verboseAutoFinish = true;
continue;
}
if (arg === '--compact-auto-finish') {
doctorDefaults.verboseAutoFinish = false;
continue;
}
forwardedArgs.push(arg);
}

return parseRepoTraversalArgs(forwardedArgs, doctorDefaults);
}

function normalizeWorkspacePath(relativePath) {
Expand Down Expand Up @@ -1309,6 +1436,7 @@ function buildSandboxDoctorArgs(options, sandboxTarget) {
if (options.skipGitignore) args.push('--no-gitignore');
if (!options.dropStaleLocks) args.push('--keep-stale-locks');
args.push(options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge');
if (options.verboseAutoFinish) args.push('--verbose-auto-finish');
if (options.json) args.push('--json');
return args;
}
Expand Down Expand Up @@ -2207,6 +2335,7 @@ function runDoctorInSandbox(options, blocked) {
postSandboxAutoFinishSummary = autoFinishReadyAgentBranches(blocked.repoRoot, {
baseBranch: blocked.branch,
dryRun: options.dryRun,
waitForMerge: options.waitForMerge,
excludeBranches: [metadata.branch],
});
}
Expand Down Expand Up @@ -2307,16 +2436,10 @@ function runDoctorInSandbox(options, blocked) {
console.log(`[${TOOL_NAME}] Auto-finish skipped: ${finishResult.note}.`);
}

if (postSandboxAutoFinishSummary.enabled) {
console.log(
`[${TOOL_NAME}] Auto-finish sweep (base=${blocked.branch}): attempted=${postSandboxAutoFinishSummary.attempted}, completed=${postSandboxAutoFinishSummary.completed}, skipped=${postSandboxAutoFinishSummary.skipped}, failed=${postSandboxAutoFinishSummary.failed}`,
);
for (const detail of postSandboxAutoFinishSummary.details) {
console.log(`[${TOOL_NAME}] ${detail}`);
}
} else if (postSandboxAutoFinishSummary.details.length > 0) {
console.log(`[${TOOL_NAME}] ${postSandboxAutoFinishSummary.details[0]}`);
}
printAutoFinishSummary(postSandboxAutoFinishSummary, {
baseBranch: blocked.branch,
verbose: options.verboseAutoFinish,
});
if (omxScaffoldSyncResult.status === 'synced') {
console.log(`[${TOOL_NAME}] Synced .omx scaffold back to protected branch workspace.`);
} else if (omxScaffoldSyncResult.status === 'unchanged') {
Expand Down Expand Up @@ -2871,6 +2994,7 @@ function hasSignificantWorkingTreeChanges(worktreePath) {
function autoFinishReadyAgentBranches(repoRoot, options = {}) {
const baseBranch = String(options.baseBranch || '').trim();
const dryRun = Boolean(options.dryRun);
const waitForMerge = options.waitForMerge !== false;
const excludedBranches = new Set(
Array.isArray(options.excludeBranches)
? options.excludeBranches.map((branch) => String(branch || '').trim()).filter(Boolean)
Expand Down Expand Up @@ -2989,7 +3113,7 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) {
'--base',
baseBranch,
'--via-pr',
'--wait-for-merge',
waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge',
'--cleanup',
];
const finishResult = run('bash', finishArgs, { cwd: repoRoot });
Expand Down Expand Up @@ -5127,31 +5251,38 @@ function doctor(rawArgs) {

const repoResults = [];
let aggregateExitCode = 0;
for (const repoPath of discoveredRepos) {
for (let repoIndex = 0; repoIndex < discoveredRepos.length; repoIndex += 1) {
const repoPath = discoveredRepos[repoIndex];
const progressLabel = `${repoIndex + 1}/${discoveredRepos.length}`;
if (!options.json) {
console.log(`[${TOOL_NAME}] ── Doctor target: ${repoPath} ──`);
console.log(`[${TOOL_NAME}] ── Doctor target: ${repoPath} [${progressLabel}] ──`);
}

const nestedResult = run(
process.execPath,
[
path.resolve(__filename),
'doctor',
'--single-repo',
'--target',
repoPath,
...(options.dropStaleLocks ? [] : ['--keep-stale-locks']),
...(options.skipAgents ? ['--skip-agents'] : []),
...(options.skipPackageJson ? ['--skip-package-json'] : []),
...(options.skipGitignore ? ['--no-gitignore'] : []),
...(options.dryRun ? ['--dry-run'] : []),
// Recursive child doctor runs should report pending PR state immediately instead of blocking the parent loop.
'--no-wait-for-merge',
...(options.json ? ['--json'] : []),
...(options.allowProtectedBaseWrite ? ['--allow-protected-base-write'] : []),
],
{ cwd: topRepoRoot },
);
const childArgs = [
path.resolve(__filename),
'doctor',
'--single-repo',
'--target',
repoPath,
...(options.dropStaleLocks ? [] : ['--keep-stale-locks']),
...(options.skipAgents ? ['--skip-agents'] : []),
...(options.skipPackageJson ? ['--skip-package-json'] : []),
...(options.skipGitignore ? ['--no-gitignore'] : []),
...(options.dryRun ? ['--dry-run'] : []),
// Recursive child doctor runs should report pending PR state immediately instead of blocking the parent loop.
'--no-wait-for-merge',
...(options.verboseAutoFinish ? ['--verbose-auto-finish'] : []),
...(options.json ? ['--json'] : []),
...(options.allowProtectedBaseWrite ? ['--allow-protected-base-write'] : []),
];
const startedAt = Date.now();
const nestedResult = options.json
? run(process.execPath, childArgs, { cwd: topRepoRoot })
: cp.spawnSync(process.execPath, childArgs, {
cwd: topRepoRoot,
encoding: 'utf8',
stdio: 'inherit',
});
if (isSpawnFailure(nestedResult)) {
throw nestedResult.error;
}
Expand Down Expand Up @@ -5181,9 +5312,12 @@ function doctor(rawArgs) {
},
);
} else {
if (nestedResult.stdout) process.stdout.write(nestedResult.stdout);
if (nestedResult.stderr) process.stderr.write(nestedResult.stderr);
process.stdout.write('\n');
console.log(
`[${TOOL_NAME}] Doctor target complete: ${repoPath} [${progressLabel}] in ${formatElapsedDuration(Date.now() - startedAt)}.`,
);
if (repoIndex < discoveredRepos.length - 1) {
process.stdout.write('\n');
}
}
}

Expand Down Expand Up @@ -5232,6 +5366,7 @@ function doctor(rawArgs) {
: autoFinishReadyAgentBranches(scanResult.repoRoot, {
baseBranch: currentBaseBranch,
dryRun: singleRepoOptions.dryRun,
waitForMerge: singleRepoOptions.waitForMerge,
});
const safe = scanResult.guardexEnabled === false || (scanResult.errors === 0 && scanResult.warnings === 0);
const musafe = safe;
Expand Down Expand Up @@ -5273,16 +5408,10 @@ function doctor(rawArgs) {
setExitCodeFromScan(scanResult);
return;
}
if (autoFinishSummary.enabled) {
console.log(
`[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`,
);
for (const detail of autoFinishSummary.details) {
console.log(`[${TOOL_NAME}] ${detail}`);
}
} else if (autoFinishSummary.details.length > 0) {
console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
}
printAutoFinishSummary(autoFinishSummary, {
baseBranch: currentBaseBranch,
verbose: singleRepoOptions.verboseAutoFinish,
});
if (safe) {
console.log(`[${TOOL_NAME}] ✅ Repo is fully safe.`);
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-21
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## Why

- `gx doctor` currently buffers nested child runs and then dumps a long wall of wrapped auto-finish output, which makes the command look frozen in large workspaces.
- Recursive doctor already forwards `--no-wait-for-merge` to child doctor runs, but the single-repo auto-finish sweep ignores that flag and can still block on merge waits.
- The default failure lines include full rebase commands and long worktree paths, which hide the actual branch state the user needs to act on.

## What Changes

- Stream recursive child doctor output live and annotate nested targets with lightweight progress and completion timing so long runs keep moving visibly.
- Thread the doctor `--no-wait-for-merge` flag into the auto-finish sweep so ready-branch cleanup does not stall recursive doctor runs.
- Compact auto-finish sweep detail lines by default while keeping `--verbose-auto-finish` as an opt-in escape hatch for the raw failure text.

## Impact

- Affects the maintainer/operator `gx doctor` UX, especially in repos with many nested git repos or many candidate agent branches.
- Keeps JSON output unchanged; only the human-readable doctor output and wait behavior change.
- Main risk: compacting failure text too aggressively could hide useful context, so verbose mode remains available and the default summary must keep the actionable reason.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
## ADDED Requirements

### Requirement: `gx doctor` keeps recursive progress visible
The human-readable `gx doctor` workflow SHALL keep progress visible while recursive child doctor runs execute, so large nested workspaces do not appear frozen.

#### Scenario: nested doctor targets stream visible progress
- **GIVEN** `gx doctor` is running recursively across multiple git repos
- **WHEN** a nested repo doctor run starts and then completes
- **THEN** the CLI SHALL print a target line for that repo before the child run
- **AND** it SHALL print a completion line with the same target plus elapsed time after that repo finishes

### Requirement: doctor sweep respects `--no-wait-for-merge`
The doctor auto-finish sweep SHALL honor the doctor wait mode when it delegates to `scripts/agent-branch-finish.sh`.

#### Scenario: no-wait mode is forwarded into ready-branch cleanup
- **GIVEN** a ready local `agent/*` branch exists during `gx doctor --no-wait-for-merge`
- **WHEN** doctor invokes the auto-finish sweep for that branch
- **THEN** it SHALL call the finish script with `--no-wait-for-merge`
- **AND** it SHALL not silently fall back to `--wait-for-merge`

### Requirement: doctor sweep output stays compact by default
The human-readable auto-finish sweep SHALL show concise actionable branch results by default and SHALL preserve the raw failure text behind an explicit verbose flag.

#### Scenario: default doctor output summarizes a long finish failure
- **GIVEN** an auto-finish failure emits a long rebase-conflict command trace
- **WHEN** `gx doctor` runs without `--verbose-auto-finish`
- **THEN** the default branch detail line SHALL summarize the actionable reason instead of dumping the full `git -C ... rebase --continue` command

#### Scenario: verbose doctor output keeps the raw finish failure text
- **GIVEN** the same auto-finish failure
- **WHEN** `gx doctor --verbose-auto-finish` runs
- **THEN** the printed branch detail SHALL include the original failure text
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `improve-gx-doctor-output-and-speed`.
- [x] 1.2 Define normative requirements in `specs/doctor-workflow/spec.md`.

## 2. Implementation

- [x] 2.1 Make recursive `gx doctor` show visible per-target progress instead of buffering nested output until each repo finishes.
- [x] 2.2 Respect doctor `--no-wait-for-merge` inside the auto-finish sweep and keep the default sweep output compact.
- [x] 2.3 Add focused install-test coverage for progress lines, no-wait forwarding, and compact-versus-verbose auto-finish rendering.

## 3. Verification

- [x] 3.1 Run focused doctor/install verification (`node --test --test-name-pattern "doctor" test/install.test.js`, `node --check bin/multiagent-safety.js`).
- [x] 3.2 Run `openspec validate agent-codex-improve-gx-doctor-output-and-speed-2026-04-21-14-15 --type change --strict`.
- [x] 3.3 Run `openspec validate --specs`.

Verification note: `node --check bin/multiagent-safety.js` passed. `node --test --test-name-pattern "doctor" test/install.test.js` passed with 17 doctor-focused tests, including the new no-wait forwarding and compact-versus-verbose output regressions. `openspec validate agent-codex-improve-gx-doctor-output-and-speed-2026-04-21-14-15 --type change --strict` passed, and `openspec validate --specs` returned `No items found to validate.`

## 4. Completion

- [ ] 4.1 Finish the agent branch via PR merge + cleanup (`gx finish --via-pr --wait-for-merge --cleanup` or `bash scripts/agent-branch-finish.sh --branch <agent-branch> --base <base-branch> --via-pr --wait-for-merge --cleanup`).
- [ ] 4.2 Record PR URL + final `MERGED` state in the completion handoff.
- [ ] 4.3 Confirm sandbox cleanup (`git worktree list`, `git branch -a`) or capture a `BLOCKED:` handoff if merge/cleanup is pending.
Loading