fix(doctor): skip GitHub issue draft for a clean session SQLite recovery (#155246)

Closes #155171

## What Problem This Solves

Doctor creates migration-failure reports and a GitHub support draft after session SQLite recovery has finished cleanly, with no remaining issues or restore conflicts.

## User Impact

Clean recovery retains its manifest path and run identifier without creating new failure-report files or offering a false support issue. Real remaining issues and restore conflicts still produce reports. Existing historical report files are preserved.

## Why This Change Was Made

Gate failure-report creation at its existing producer, after restoration, validation, and restore-conflict aggregation. A zero-issue report returns before failure artifacts or the support payload are authored. Recovery itself, stored formats, consent, and GitHub submission behavior are unchanged; this is not a special case for zero file moves.

The ordinary updater and Doctor health implementations are untouched. The change is limited to recovery-report production after the existing restore and validation work.

## Evidence

The real built CLI was run once before and once after the change against separate, otherwise equivalent synthetic recovery fixtures:

```sh
node openclaw.mjs doctor --session-sqlite recover --github-issue --json \
  --session-sqlite-store <isolated-store> --session-sqlite-agent main
```

Both runs deliberately omitted `--yes`. Their state, home, and temporary directories were isolated, and credentials were omitted from the child environment.

| Observation | Before | After |
| --- | --- | --- |
| Remaining issues / restore conflicts | 0 / 0 | 0 / 0 |
| Restore activity | No-op | No-op |
| Recovery manifest and run identifier | Retained | Retained |
| New `.failure.json` and `.failure.md` | Both created | Neither created |
| Support-issue payload | Present; GitHub status `skipped` | Absent |
| CLI exit | 0 | 0 |

- The two clean-recovery regression cases fail against the original producer and pass after the fix. They cover fresh recovery and byte-for-byte preservation of existing historical reports.
- **41/41 focused tests passed** across report, recovery, and manifest suites, including a real filesystem restore conflict that remains reported without replacing the current source, existing remaining-issue reporting, sanitization, and restore controls. Combined command wall: 47.40 seconds; the changed test file's 15 test bodies took 4.133 seconds. Hosted CI timing is not yet available.
- The actual Doctor test-type project, targeted type-aware lint, formatting, line-cap, max-lines, assertion-safety, and scoped diff checks passed.
- The candidate runtime was built after contributor-preserving integration; its complete source tree matches the tested tree.

### Published updater compatibility

One isolated, unprivileged **bare Docker** cell installed the integrity-pinned published `openclaw@2026.9.5` package. That installed release's public SDK created the old configuration state; its updater then installed the exact candidate tarball with `update --tag file:<candidate> --yes --json --no-restart`.

The result was `status: "ok"`: the installed build changed from release `ec9c1a13db89` to candidate `cebc02f6de19`, and the authored configuration values survived. The maintained upgrade-success assertion passed. Normal migration, health, configuration, plugin, Gateway-startup, and post-install Doctor checks completed; nonfatal environment advisories were retained, not hidden. This was one compatibility cell, not a full upgrade matrix.

The host's global `/tmp`, sockets, credential directories, and operator state were not mounted into the container; only the task-owned evidence mount was writable. The container and its task-owned image were removed afterward.

No live GitHub issue was submitted. The standalone recovery runs used the verified JSON/no-yes consent return and exited before the full Doctor health flow. Normal update-time Doctor execution occurred only inside the isolated container, never against host operator state. Interactive issue consent was not exercised.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Jialong(Bruce) Li
2026-09-22 17:44:27 +05:30
committed by GitHub
co-authored by Ayaan Zaidi
parent cc216b8775
commit 86fc1df067
2 changed files with 88 additions and 4 deletions
@@ -134,11 +134,15 @@ export async function recoverDoctorSessionSqliteTargets(params: {
})),
);
const report = summarizeRecoverReport(targetReports.length > 0 ? targetReports : [reportTarget]);
if (report.totals.issues === 0) {
report.migrationRun = {
manifestPath: failedRun.manifestPath,
runId: failedRun.manifest.runId,
};
return report;
}
const failureReports = writeSessionSqliteMigrationFailureReports(failedRun.manifestPath, {
reason:
report.totals.issues > 0
? "doctor recover completed with remaining issues"
: "doctor recover completed validation of a failed session SQLite migration run",
reason: "doctor recover completed with remaining issues",
recoveryTargets: report.targets,
trustedTargets,
});
@@ -131,6 +131,86 @@ describe("runDoctorSessionSqlite", () => {
expect(recover.supportIssue).not.toHaveProperty("url");
});
it("keeps a support report for a restore conflict without replacing the current source", async () => {
const store = createLegacyStore();
const imported = await importLegacyStore(store);
const manifestPath = requireMigrationManifestPath(imported.migrationRun?.manifestPath);
const manifest = readMigrationManifest(manifestPath);
manifest.failedAt = "2030-01-01T00:00:00.000Z";
fs.writeFileSync(manifestPath, JSON.stringify(manifest), { mode: 0o600 });
const replacement = '{"type":"event","id":"newer-source"}\n';
fs.writeFileSync(store.transcriptPath, replacement, { mode: 0o600 });
const recover = await runDoctorSessionSqlite({ cfg: {}, env: store.env, mode: "recover" });
expect(recover.targets[0]?.restore?.conflicts).toEqual(
expect.arrayContaining([
expect.objectContaining({ sourcePath: canonicalTestPaths([store.transcriptPath])[0] }),
]),
);
expect(recover.targets[0]?.issues).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "restore_conflict" })]),
);
expect(recover.supportIssue?.body).toContain("restore_conflict");
expect(fs.readFileSync(store.transcriptPath, "utf8")).toBe(replacement);
});
it.each([false, true])(
"does not prepare failure reports for clean recovery (existing reports=%s)",
async (existingReports) => {
const store = createLegacyStore();
for (const file of [
store.storePath,
store.transcriptPath,
store.trajectoryPath,
store.unreferencedJsonlPath,
]) {
fs.rmSync(file);
}
const runsDir = path.join(store.stateDir, "session-sqlite-migration-runs");
fs.mkdirSync(runsDir, { recursive: true, mode: 0o700 });
const manifestPath = path.join(runsDir, "clean-recovery.json");
const manifest: SessionSqliteMigrationManifest = {
failedAt: "2030-01-01T00:00:00.000Z",
manifestVersion: 3,
openClawVersion: "test",
runId: "clean-recovery",
startedAt: "2030-01-01T00:00:00.000Z",
targets: [
{
...trustedMigrationTarget(store),
completedMoves: [],
issues: [],
plannedMoves: [],
validationBeforeArchive: "not_run",
},
],
};
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
const previousReports = existingReports
? writeSessionSqliteMigrationFailureReports(manifestPath, { reason: "Earlier recovery" })
: undefined;
const previousBytes = previousReports
? [previousReports.jsonPath, previousReports.markdownPath].map((file) =>
fs.readFileSync(file),
)
: undefined;
const recover = await runDoctorSessionSqlite({ cfg: {}, env: store.env, mode: "recover" });
expect(recover.totals.issues).toBe(0);
expect(recover.migrationRun).toEqual({ manifestPath, runId: "clean-recovery" });
expect(recover.supportIssue).toBeUndefined();
if (previousReports && previousBytes) {
expect(fs.readFileSync(previousReports.jsonPath)).toEqual(previousBytes[0]);
expect(fs.readFileSync(previousReports.markdownPath)).toEqual(previousBytes[1]);
} else {
expect(fs.existsSync(manifestPath.replace(/\.json$/u, ".failure.json"))).toBe(false);
expect(fs.existsSync(manifestPath.replace(/\.json$/u, ".failure.md"))).toBe(false);
}
},
);
it.each(["replaced", "missing"] as const)(
"refuses a support claim when the saved report is %s during consent",
(change) => {