diff --git a/docs/trajectories/typescript-bun-migration/RESUME.md b/docs/trajectories/typescript-bun-migration/RESUME.md index 778d78832..b9f863d0d 100644 --- a/docs/trajectories/typescript-bun-migration/RESUME.md +++ b/docs/trajectories/typescript-bun-migration/RESUME.md @@ -1,9 +1,9 @@ # Trajectory — TypeScript / Bun migration -**Status**: Active (Lane B slice 11 merged — [#884](https://github.com/Lucent-Financial-Group/Zeta/pull/884), commit `9237756`) -**Milestone**: 32 ported + 1 in-flight = 33 total (2 from #849 + 3 from #866 + 3 from #868 + 3 from #870 + 2 from #872 + 3 from #874 + 3 from #876 + 3 from #878 + 3 from #880 + 3 from #882 + 2 from #883 + 2 from #884 = 32 merged; +1 in-flight in slice-12). Slice-12 opens **backlog index regenerator** (backlog/generate-index). 10 Bucket B files remain. +**Status**: Active (Lane B slice 12 merged — [#885](https://github.com/Lucent-Financial-Group/Zeta/pull/885), commit `cfb5964`) +**Milestone**: 33 ported + 1 in-flight = 34 total (2 from #849 + 3 from #866 + 3 from #868 + 3 from #870 + 2 from #872 + 3 from #874 + 3 from #876 + 3 from #878 + 3 from #880 + 3 from #882 + 2 from #883 + 2 from #884 + 1 from #885 = 33 merged; +1 in-flight in slice-13). Slice-13 opens **git-cluster** (git/push-with-retry). 9 Bucket B files remain. **Current blocker**: None. -**Next concrete action**: Pick a coherent next slice from Bucket B (10 files remaining). Per Gate B: read-only scope first, then re-verify the layered baseline currency before first mutating action. +**Next concrete action**: Pick a coherent next slice from Bucket B (9 files remaining). Per Gate B: read-only scope first, then re-verify the layered baseline currency before first mutating action. **Last updated**: 2026-04-30 ## Why this trajectory exists @@ -64,17 +64,16 @@ tools/profile.sh Rationale: TS/Bun is itself one of the things `install.sh` installs. These scripts cannot depend on Bun. -### Bucket B — Should become TypeScript (10 files remaining) +### Bucket B — Should become TypeScript (9 files remaining) -Post-install scripts that operate on the repo (lints, audits, hygiene checks, peer-call wrappers, budget reports, git ops). Same shape as the scripts ported in #849, #866, #868, #870, #872, #874, #876, #878, #880, #882, #883, #884. The originally-listed audit/lint scripts have progressively ported (1 in slice-12 in flight); the bash originals remain in-tree as the equivalence reference and will retire once the TS ports have soaked. +Post-install scripts that operate on the repo (lints, audits, hygiene checks, peer-call wrappers, budget reports, git ops). Same shape as the scripts ported in #849, #866, #868, #870, #872, #874, #876, #878, #880, #882, #883, #884, #885. The originally-listed audit/lint scripts have progressively ported (1 in slice-13 in flight — git/push-with-retry); the bash originals remain in-tree as the equivalence reference and will retire once the TS ports have soaked. ```text -tools/backlog/generate-index.sh # in flight (slice 12) tools/budget/daily-cost-report.sh tools/budget/project-runway.sh tools/budget/snapshot-burn.sh tools/git/batch-resolve-pr-threads.sh -tools/git/push-with-retry.sh +tools/git/push-with-retry.sh # in flight (slice 13) tools/peer-call/codex.sh tools/peer-call/gemini.sh tools/peer-call/grok.sh @@ -133,6 +132,7 @@ tools/hygiene/counterweight-audit.sh # ported in #883 tools/hygiene/append-tick-history-row.sh # ported in #883 tools/skill-catalog/backfill_dv2_frontmatter.sh # ported in #884 tools/audit-packages.sh # ported in #884 +tools/backlog/generate-index.sh # ported in #885 ``` ## Recommended next slice diff --git a/docs/trajectories/typescript-bun-migration/slice-audits.md b/docs/trajectories/typescript-bun-migration/slice-audits.md index 94192acf3..656995e1d 100644 --- a/docs/trajectories/typescript-bun-migration/slice-audits.md +++ b/docs/trajectories/typescript-bun-migration/slice-audits.md @@ -411,6 +411,30 @@ Per-port pattern checklist: Slice 6 passes audit. No new patterns recorded — all reused from prior slices. +## Slice 13 — 1 port (git/push-with-retry — git-cluster opens) (PR pending — `lane-b/ts-bun-slice-13-push-with-retry-2026-04-30`) + +**Slice files**: + +- `tools/git/push-with-retry.{sh→ts}` (`git push` retry wrapper for transient GitHub 5xx) + +**Comparison points**: identical to slice 12. Within Gate B 30-day window. tsc gate now active in CI per PR #890. + +### Code-pattern audit (per-port) + +- **`push-with-retry.ts`** (129 → 184 lines): bash `[[ =~ $int_re ]]` regex → `POSITIVE_INT_RE.test()`. Bash `mktemp` + tee + grep on tmp file → `spawnSync` with `stdio: ['inherit', 'inherit', 'pipe']` capturing stderr in-memory; `TRANSIENT_5XX_RE.test()` against captured string. Bash `set +e; git push; exit_code=$?; set -e` → `classifySpawnFailure` helper handling 4 cases: status set / ENOENT (return 127 like bash command-not-found) / other spawn error / signal-terminated. Bash `sleep "$backoff"` → `Atomics.wait(view, 0, 0, seconds * 1000)` synchronous-sleep pattern (preserves the script's synchronous flow; async setTimeout would change exit-code semantics). Exponential backoff doubling preserved (`backoff *= 2`). DST-ACCEPTED-BOUNDARY classification preserved in header comment (Otto-168 boundary registry §3). + +### Equivalence audit + +- **`push-with-retry`**: byte-equivalent on env-validation paths (invalid `GIT_PUSH_MAX_ATTEMPTS=foo` → exit 2 with same message; invalid `GIT_PUSH_BACKOFF_S=-1` → exit 2). Network-dependent retry path can't be tested without an actual 5xx; success path tests would need a real push (deferred to live-fire usage). + +### Behavioural note vs bash original + +The bash version uses `tee` to BOTH capture stderr AND stream it live during each attempt. The TS port uses `spawnSync` which captures-then-replays stderr after each attempt completes. For typical git-push runtimes (seconds) the UX difference is invisible; for long pushes the stderr appears in batches per attempt rather than streaming live. Documented in port file header. + +### Outcome + +Slice 13 passes audit. **Git-cluster opens** (first of 2 git scripts). Bucket B 10 → 9. The remaining cluster — peer-call trio + budget trio + git/batch-resolve + pr-preservation/archive-pr — all need careful design (LLM non-determinism, shared-state mutation, gh API mutation). + ## Slice 12 — 1 port (backlog index regenerator) (PR pending — `lane-b/ts-bun-slice-12-backlog-generate-index-2026-04-30`) **Slice files**: diff --git a/tools/git/push-with-retry.ts b/tools/git/push-with-retry.ts new file mode 100644 index 000000000..c9333fd2d --- /dev/null +++ b/tools/git/push-with-retry.ts @@ -0,0 +1,187 @@ +#!/usr/bin/env bun +// push-with-retry.ts — `git push` retry wrapper for transient 5xx. +// +// TypeScript+Bun port of push-with-retry.sh, slice 13 of the TS+Bun +// migration. See docs/best-practices/repo-scripting.md. +// +// Why this exists: +// The factory observed recurring transient GitHub 500s during +// autonomous-loop tick-close commits (multiple occurrences +// 2026-04-23). Manual retry burns tick budget; this wrapper makes +// the retry uniform. +// +// DST classification: ACCEPTED_BOUNDARY (external network I/O + +// retry-on-failure) per the boundary registry at +// `docs/research/dst-accepted-boundaries.md` §3 and Otto-168. +// +// Usage: +// bun tools/git/push-with-retry.ts [git push args...] +// bun tools/git/push-with-retry.ts +// bun tools/git/push-with-retry.ts --set-upstream origin my-branch +// +// Exit codes: +// 0 push succeeded (possibly after retries) +// 1 all retries exhausted on transient 5xx +// 2 environment validation failed (non-integer env value) +// N non-transient error — propagates `git push`'s own exit code +// +// Environment: +// GIT_PUSH_MAX_ATTEMPTS override retry count (default 3; positive int) +// GIT_PUSH_BACKOFF_S override initial backoff seconds (default 2; +// doubles each retry; non-negative int) +// +// Behavioural note vs bash original: +// The bash version uses `tee` to BOTH capture stderr AND stream it +// live. This port uses spawnSync which captures-then-replays stderr +// after each attempt completes. For typical git-push runtimes +// (seconds) the UX difference is invisible; for long pushes the +// stderr appears in batches per attempt rather than streaming. + +import { spawnSync } from "node:child_process"; + +const POSITIVE_INT_RE = /^\d+$/; + +const TRANSIENT_5XX_RE = + /(500|502|503|504|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)/; + +interface Validated { + readonly maxAttempts: number; + readonly initialBackoff: number; +} + +function validateEnv(): Validated | { readonly error: string } { + const maxAttemptsRaw = process.env.GIT_PUSH_MAX_ATTEMPTS ?? "3"; + const backoffRaw = process.env.GIT_PUSH_BACKOFF_S ?? "2"; + + if (!POSITIVE_INT_RE.test(maxAttemptsRaw)) { + return { + error: `push-with-retry: GIT_PUSH_MAX_ATTEMPTS must be a positive integer; got '${maxAttemptsRaw}'`, + }; + } + const maxAttempts = Number.parseInt(maxAttemptsRaw, 10); + if (maxAttempts < 1) { + return { + error: `push-with-retry: GIT_PUSH_MAX_ATTEMPTS must be a positive integer; got '${maxAttemptsRaw}'`, + }; + } + + if (!POSITIVE_INT_RE.test(backoffRaw)) { + return { + error: `push-with-retry: GIT_PUSH_BACKOFF_S must be a non-negative integer; got '${backoffRaw}'`, + }; + } + const initialBackoff = Number.parseInt(backoffRaw, 10); + + return { maxAttempts, initialBackoff }; +} + +function sleepSeconds(seconds: number): void { + // Synchronous sleep via Atomics.wait on a SharedArrayBuffer. The + // shebang pins this to Bun, which supports main-thread Atomics.wait + // (Node's main thread throws TypeError; off-thread workers in either + // runtime work too). We need synchronous because the entry guard + // `process.exit(main(...))` depends on `main` returning synchronously. + const sab = new SharedArrayBuffer(4); + const view = new Int32Array(sab); + Atomics.wait(view, 0, 0, seconds * 1000); +} + +interface SpawnError { + readonly code?: string; +} + +function classifySpawnFailure( + status: number | null, + signal: string | null, + error: SpawnError | undefined, +): { readonly status: number; readonly note: string } { + if (status !== null) return { status, note: "" }; + // status === null implies either a signal termination or a spawn + // failure (ENOENT / EACCES / EAGAIN). Match bash exit-code conventions + // where practical: 127 for command-not-found, 1 otherwise. + if (error?.code === "ENOENT") { + return { status: 127, note: "git not found on PATH (ENOENT)" }; + } + if (error?.code !== undefined) { + return { status: 1, note: `spawn failed (${error.code})` }; + } + if (signal !== null) { + return { status: 1, note: `git terminated by signal ${signal}` }; + } + return { status: 1, note: "git terminated without exit code" }; +} + +function runOnce(args: readonly string[]): { + status: number; + stderr: string; +} { + // `git` invocation: no shell interpolation (args passed as + // separate array elements); user-provided args go directly to git + // push, not the shell. Same security posture as the bash original + // which runs `git push "$@"` in an unquoted-glob-safe way. + // eslint-disable-next-line sonarjs/no-os-command-from-path + const result = spawnSync("git", ["push", ...args], { + stdio: ["inherit", "inherit", "pipe"], + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + // Defensive: @types/node typings claim `stderr: string` (when + // encoding is set), but the runtime can return `null` when the + // child cannot start (ENOENT/EACCES). Guard with `?? ""` so the + // downstream regex match against the empty string is safe. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const stderr = result.stderr ?? ""; + // Replay captured stderr to the user (bash original streams via + // tee; we batch). + if (stderr.length > 0) { + process.stderr.write(stderr); + } + const classified = classifySpawnFailure( + result.status, + result.signal, + result.error as SpawnError | undefined, + ); + if (classified.note.length > 0) { + process.stderr.write(`push-with-retry: ${classified.note}\n`); + } + return { status: classified.status, stderr }; +} + +export function main(args: readonly string[]): number { + const validated = validateEnv(); + if ("error" in validated) { + process.stderr.write(`${validated.error}\n`); + return 2; + } + + let backoff = validated.initialBackoff; + for (let attempt = 1; attempt <= validated.maxAttempts; attempt++) { + const { status, stderr } = runOnce(args); + if (status === 0) return 0; + + if (TRANSIENT_5XX_RE.test(stderr)) { + if (attempt < validated.maxAttempts) { + process.stderr.write( + `push-with-retry: transient 5xx on attempt ${String(attempt)}/${String(validated.maxAttempts)}; retrying in ${String(backoff)}s...\n`, + ); + if (backoff > 0) sleepSeconds(backoff); + backoff *= 2; + continue; + } + process.stderr.write( + `push-with-retry: failed after ${String(validated.maxAttempts)} attempts on transient 5xx\n`, + ); + return 1; + } + + // Non-transient error — propagate git push's own exit code. + return status; + } + + // Unreachable (the loop always returns), but TS needs an exit. + return 1; +} + +if (import.meta.main) { + process.exit(main(process.argv.slice(2))); +}