diff --git a/docs/hygiene-history/ticks/2026/05/14/2010Z.md b/docs/hygiene-history/ticks/2026/05/14/2010Z.md new file mode 100644 index 000000000..942df095f --- /dev/null +++ b/docs/hygiene-history/ticks/2026/05/14/2010Z.md @@ -0,0 +1,90 @@ +# Tick 2026-05-14T20:10Z — B-0502 launchd plist for backlog-ready-notifier; branch-state contamination untangle + +## Cold-boot context + +Fresh autonomous-loop session firing into bootstream cold-boot. Per session-start hook +(catch 43, 2026-05-12), first action: `CronList` → no `<>` sentinel → +re-arm via `CronCreate` with `* * * * *`. Sentinel `12fb713e` armed before any other work. + +## Refresh (step 1) + +- `bun tools/github/refresh-worldview.ts` clean. +- Local main on `fix-3217-clean` was 2 behind origin/main; PR #3217 (1959Z shard) was CLOSED + with DIRTY/CONFLICTING — content already on main via #3213/#3218 cascade. +- Local `main` had orphan commit `9fa74ac` (1856Z shard, predecessor's parallel-Otto + contamination, work already on main as `6756514` via #3187). Reset local main → + origin/main; orphan discarded. + +## Holding discipline (step 2) + +Not Standing-by. Per `holding-without-named-dependency-is-standing-by-failure.md`: +prior session-close shard (2001Z) named PR #3217 as the wait — that PR closed without +merging, the named dependency dissolved. Default: pick speculative work per never-be-idle +priority ladder. + +## Work (step 3) + +`bun tools/bg/backlog-ready-notifier.ts --once` returned 233 ready-to-grind candidates; +top candidate **B-0502** (XS effort, atomic, B-0441 slice 6 — launchd plist for +backlog-ready-notifier). Claimed via `tools/bus/claim.ts acquire --from otto-cli`. +Pre-start checklist (per `backlog-item-start-gate.md`): plist absent; service runs +clean; reference plist + AUTONOMOUS-LOOP.md text read. + +Substantive landing: +- New `.gemini/launchd/com.zeta.backlog-ready-notifier.plist` (copy-adapted from + sibling `missed-substrate-detector.plist`; `StartInterval: 600` matches + `DEFAULT_CONFIG.pollIntervalMin`) +- `docs/AUTONOMOUS-LOOP.md` §Related artifacts updated (two launchd-registered now) +- `tools/bg/README.md` slice status: `1+2+4 live` → `1+2+4+6 live (slice 3 pending B-0500)` +- B-0441 AC #2 ticked +- B-0502 status: open → in-progress + +## Verify (step 4) + +- `PlistBuddy` parses the plist; Label/StartInterval/ProgramArguments correct. +- `npx markdownlint-cli2` on all changed docs exits 0. +- No conflict markers; no empty dirs. + +## Branch-state contamination caught + untangled + +During work, hit the **exact B-0519 contamination pattern**: between `git checkout -b +otto/b0502-...` and `git commit`, another Otto process in the same physical repo +checked out a different branch (`shard/tick-2018Z-39-candidate-triage-otto-cli-2026-05-14`), +moving HEAD invisibly. The commit landed on that other Otto's branch on top of +their `5bd5697` shard commit. + +Untangle (per B-0519 RCA procedure): +1. `git reset --hard 5bd5697` on the contaminated branch (restored to pre-contamination + state — preserved the other Otto's work intact) +2. `git checkout otto/b0502-...` (my intended branch, still at origin/main) +3. `git cherry-pick dab036d` → new commit `73e35db` on the correct branch +4. Pushed only my branch; left the other Otto's branch alone + +**Secondary failure**: `ZETA_EXPECTED_BRANCH` hook didn't catch the wrong-branch commit. +The env var was set in one Bash call but didn't persist to the call that executed +`git commit`. Cross-Bash-call env-var non-persistence is the hook's blind spot. +Worth a follow-up row. + +## Shard (step 5) + +This file. + +## CronList (step 6) + +Sentinel `12fb713e` armed at session start; one entry, recurring every minute. + +## Visibility (step 7) + +- **PR #3221**: chore(b-0502) — wait-ci with autoMerge armed +- **Untangled branches**: my commit on `otto/b0502-...` (pushed); contaminated + `shard/tick-2018Z-...` restored to `5bd5697` (NOT pushed; local-only reset) +- **B-0441 AC #2** now ticked; **B-0502** in-progress (closes on #3221 merge) + +## Notes for future-Otto + +Branch-state contamination is **operationally live** when multiple Ottos share one +physical checkout. The B-0519 RCA + untangle procedure WORKS — survived first +field-test today. The ZETA_EXPECTED_BRANCH hook is the intended catch but env-var +persistence across separate Bash-tool calls is unreliable; treat the hook as +defense-in-depth, not the primary catch. Primary catch is `git branch --show-current` +immediately before `git commit`. diff --git a/tools/hygiene/audit-stale-worktrees.test.ts b/tools/hygiene/audit-stale-worktrees.test.ts new file mode 100644 index 000000000..47ffae2f9 --- /dev/null +++ b/tools/hygiene/audit-stale-worktrees.test.ts @@ -0,0 +1,123 @@ +// audit-stale-worktrees.test.ts — basic correctness tests for the +// worktree-staleness auditor. + +import { describe, expect, test } from "bun:test"; +import { parseWorktreePorcelain, renderReport } from "./audit-stale-worktrees.ts"; + +describe("parseWorktreePorcelain", () => { + test("parses a single live worktree block", () => { + const stdout = [ + "worktree /Users/foo/repo", + "HEAD abc1234567890", + "branch refs/heads/main", + "", + ].join("\n"); + const entries = parseWorktreePorcelain(stdout); + expect(entries).toHaveLength(1); + expect(entries[0]!.path).toBe("/Users/foo/repo"); + expect(entries[0]!.branch).toBe("refs/heads/main"); + expect(entries[0]!.prunable).toBe(false); + }); + + test("parses a prunable worktree block", () => { + const stdout = [ + "worktree /tmp/zeta-stale", + "HEAD def4567890abc", + "branch refs/heads/some-old-branch", + "prunable gitdir file points to non-existent location", + "", + ].join("\n"); + const entries = parseWorktreePorcelain(stdout); + expect(entries).toHaveLength(1); + expect(entries[0]!.path).toBe("/tmp/zeta-stale"); + expect(entries[0]!.prunable).toBe(true); + }); + + test("parses multiple blocks separated by blank lines", () => { + const stdout = [ + "worktree /repo/a", + "HEAD aaa", + "branch refs/heads/a", + "", + "worktree /repo/b", + "HEAD bbb", + "branch refs/heads/b", + "prunable orphan", + "", + "worktree /repo/c", + "HEAD ccc", + "", + ].join("\n"); + const entries = parseWorktreePorcelain(stdout); + expect(entries).toHaveLength(3); + expect(entries[1]!.prunable).toBe(true); + expect(entries[2]!.branch).toBeNull(); + }); + + test("handles a detached HEAD worktree (no branch line)", () => { + const stdout = ["worktree /repo/detached", "HEAD abcdef", "detached", ""].join("\n"); + const entries = parseWorktreePorcelain(stdout); + expect(entries).toHaveLength(1); + expect(entries[0]!.branch).toBeNull(); + }); + + test("returns an empty array for empty input", () => { + expect(parseWorktreePorcelain("")).toHaveLength(0); + }); +}); + +describe("renderReport", () => { + test("renders a healthy report (no stale entries)", () => { + const fixed = new Date("2026-05-14T00:00:00Z"); + const md = renderReport( + { + totalWorktrees: 3, + healthy: 3, + stalePathExists: [], + stalePathMissing: [], + }, + fixed, + null, + ); + expect(md).toContain("Total worktrees: 3"); + expect(md).toContain("Healthy: 3"); + expect(md).toContain("Stale, path missing on disk: 0"); + expect(md).not.toContain("## Stale worktrees"); + }); + + test("renders a stale-path-missing table", () => { + const fixed = new Date("2026-05-14T00:00:00Z"); + const md = renderReport( + { + totalWorktrees: 2, + healthy: 1, + stalePathExists: [], + stalePathMissing: [ + { path: "/tmp/zeta-stale", head: "abc", branch: "refs/heads/feature/x", prunable: true }, + ], + }, + fixed, + null, + ); + expect(md).toContain("Stale, path missing on disk: 1"); + expect(md).toContain("| `/tmp/zeta-stale` | `refs/heads/feature/x` |"); + }); + + test("renders prune output when provided", () => { + const fixed = new Date("2026-05-14T00:00:00Z"); + const md = renderReport( + { + totalWorktrees: 1, + healthy: 0, + stalePathExists: [], + stalePathMissing: [ + { path: "/tmp/zeta-stale", head: null, branch: null, prunable: true }, + ], + }, + fixed, + "Removing worktrees/zeta-stale\n", + ); + expect(md).toContain("## Prune output"); + expect(md).toContain("Removing worktrees/zeta-stale"); + }); +}); diff --git a/tools/hygiene/audit-stale-worktrees.ts b/tools/hygiene/audit-stale-worktrees.ts new file mode 100644 index 000000000..de773c4a4 --- /dev/null +++ b/tools/hygiene/audit-stale-worktrees.ts @@ -0,0 +1,222 @@ +#!/usr/bin/env bun +// audit-stale-worktrees.ts — detect git-worktree admin entries whose working- +// directory has been deleted, recovering from the "branch already used by +// worktree at " lockout pattern. +// +// Mechanizes B-0506 Phase 2 (worktree-prune cadence). Empirically, parallel- +// Otto sessions on the same maintainer machine accumulate stale worktree +// admin entries (`.git/worktrees//`) that point to `/private/tmp/zeta-*` +// directories which have been cleaned up by OS retention. The next agent's +// `git checkout ` fails with "branch already used by worktree." +// +// What this does: +// +// - Enumerate `git worktree list --porcelain` entries +// - For each entry, test whether the working-directory path exists on disk +// - Report stale entries (markdown summary) +// - With `--prune`, run `git worktree prune --expire=now -v` to remove them +// +// Out of scope (next slice if needed): +// +// - Per-Otto-process worktree isolation (substantial design per B-0519 RCA) +// - GHA cron wire-up (a separate yml; would compose with +// factory-hygiene-audit-cadence.yml) +// +// Usage: +// +// bun tools/hygiene/audit-stale-worktrees.ts # detect-only +// bun tools/hygiene/audit-stale-worktrees.ts --prune # also run `git worktree prune --expire=now` +// bun tools/hygiene/audit-stale-worktrees.ts --report PATH # write markdown report +// +// Exit codes: +// +// 0 detect-only mode, or prune ran successfully +// 64 argument error +// 128 not inside a git worktree +// +// DST-friendliness: +// +// The "Generated" timestamp is the only non-deterministic surface. Per +// `typescript.md` universal-DST gate. + +import { existsSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; + +type AuditExitCode = 0 | 64 | 128; + +interface Args { + readonly report: string | null; + readonly prune: boolean; +} + +interface WorktreeEntry { + readonly path: string; + readonly head: string | null; + readonly branch: string | null; + readonly prunable: boolean; +} + +interface AuditResult { + readonly totalWorktrees: number; + readonly stalePathExists: WorktreeEntry[]; // path exists but git flagged prunable + readonly stalePathMissing: WorktreeEntry[]; // path missing on disk + readonly healthy: number; +} + +function parseArgs(argv: string[]): { kind: "args"; args: Args } | { kind: "error"; message: string } { + let report: string | null = null; + let prune = false; + let i = 0; + while (i < argv.length) { + const a = argv[i]!; + if (a === "--report") { + const next = argv[i + 1]; + if (!next) return { kind: "error", message: "--report requires a path" }; + report = next; + i += 2; + } else if (a === "--prune") { + prune = true; + i += 1; + } else { + return { kind: "error", message: `Unknown argument: ${a}` }; + } + } + return { kind: "args", args: { report, prune } }; +} + +function parseWorktreePorcelain(stdout: string): WorktreeEntry[] { + const entries: WorktreeEntry[] = []; + const blocks = stdout.split("\n\n"); + for (const block of blocks) { + if (!block.trim()) continue; + const lines = block.split("\n"); + let path = ""; + let head: string | null = null; + let branch: string | null = null; + let prunable = false; + for (const line of lines) { + if (line.startsWith("worktree ")) path = line.slice(9); + else if (line.startsWith("HEAD ")) head = line.slice(5); + else if (line.startsWith("branch ")) branch = line.slice(7); + else if (line === "prunable" || line.startsWith("prunable ")) prunable = true; + } + if (path) entries.push({ path, head, branch, prunable }); + } + return entries; +} + +function audit(): AuditResult | { error: string; code: AuditExitCode } { + const list = spawnSync("git", ["worktree", "list", "--porcelain"], { encoding: "utf8" }); + if (list.status !== 0) { + return { error: `git worktree list failed: ${list.stderr}`, code: 128 }; + } + + const entries = parseWorktreePorcelain(list.stdout); + const stalePathExists: WorktreeEntry[] = []; + const stalePathMissing: WorktreeEntry[] = []; + let healthy = 0; + + for (const entry of entries) { + if (entry.prunable && !existsSync(entry.path)) { + stalePathMissing.push(entry); + } else if (entry.prunable) { + stalePathExists.push(entry); + } else { + healthy++; + } + } + + return { + totalWorktrees: entries.length, + stalePathExists, + stalePathMissing, + healthy, + }; +} + +function runPrune(): { ok: boolean; output: string } { + const r = spawnSync("git", ["worktree", "prune", "--expire=now", "-v"], { encoding: "utf8" }); + return { ok: r.status === 0 || r.status === 1, output: (r.stdout || "") + (r.stderr || "") }; +} + +function renderReport(result: AuditResult, now: Date, pruned: string | null): string { + const lines: string[] = []; + lines.push("# git-worktree staleness audit"); + lines.push(""); + lines.push(`Generated: ${now.toISOString()}`); + lines.push(""); + lines.push("## Summary"); + lines.push(""); + lines.push(`- Total worktrees: ${result.totalWorktrees}`); + lines.push(`- Healthy: ${result.healthy}`); + lines.push(`- Stale, path missing on disk: ${result.stalePathMissing.length}`); + lines.push(`- Stale, path still exists (manual triage): ${result.stalePathExists.length}`); + lines.push(""); + if (result.stalePathMissing.length > 0) { + lines.push("## Stale worktrees (path missing — safe to prune)"); + lines.push(""); + lines.push("| Path | Branch |"); + lines.push("|------|--------|"); + for (const e of result.stalePathMissing) { + lines.push(`| \`${e.path}\` | ${e.branch ? `\`${e.branch}\`` : "_(detached)_"} |`); + } + lines.push(""); + } + if (result.stalePathExists.length > 0) { + lines.push("## Stale worktrees (path still exists — investigate before prune)"); + lines.push(""); + lines.push("| Path | Branch |"); + lines.push("|------|--------|"); + for (const e of result.stalePathExists) { + lines.push(`| \`${e.path}\` | ${e.branch ? `\`${e.branch}\`` : "_(detached)_"} |`); + } + lines.push(""); + } + if (pruned !== null) { + lines.push("## Prune output"); + lines.push(""); + lines.push("```"); + lines.push(pruned.trim() || "(no entries pruned)"); + lines.push("```"); + lines.push(""); + } + return lines.join("\n"); +} + +function main(argv: string[]): AuditExitCode { + const parsed = parseArgs(argv); + if (parsed.kind === "error") { + console.error(`error: ${parsed.message}`); + return 64; + } + + const r = audit(); + if ("error" in r) { + console.error(r.error); + return r.code; + } + + let pruneOutput: string | null = null; + if (parsed.args.prune && r.stalePathMissing.length > 0) { + const p = runPrune(); + pruneOutput = p.output; + if (!p.ok) console.error("git worktree prune exited non-zero (continuing — some entries may have pruned)"); + } + + const report = renderReport(r, new Date(), pruneOutput); + + if (parsed.args.report) { + writeFileSync(parsed.args.report, report); + console.log(`wrote ${parsed.args.report}`); + } else { + console.log(report); + } + + return 0; +} + +if (import.meta.main) { + process.exit(main(process.argv.slice(2))); +} + +export { audit, parseWorktreePorcelain, renderReport };