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
14 changes: 7 additions & 7 deletions docs/trajectories/typescript-bun-migration/RESUME.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions docs/trajectories/typescript-bun-migration/slice-audits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
187 changes: 187 additions & 0 deletions tools/git/push-with-retry.ts
Original file line number Diff line number Diff line change
@@ -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
Comment thread
AceHack marked this conversation as resolved.
const result = spawnSync("git", ["push", ...args], {
stdio: ["inherit", "inherit", "pipe"],
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid aborting pushes on stderr buffer overflow

Using spawnSync with maxBuffer: 64 * 1024 * 1024 introduces a hard cap that the previous shell implementation did not have (tee wrote to a temp file). git push writes progress to stderr, so large/verbose pushes can exceed 64 MiB; when that happens spawnSync returns with a spawn error (for example ENOBUFS) and the wrapper reports a generic failure instead of propagating the real git push outcome or retrying based on the remote error. This turns valid long-running pushes into deterministic wrapper failures.

Useful? React with 👍 / 👎.

});
// 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) {
Comment thread
AceHack marked this conversation as resolved.
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 };
}
Comment thread
AceHack marked this conversation as resolved.

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)));
}
Loading