Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b84ec55
fix(gbrain-sync): --full produces an empty code index on first run of…
garrytan May 21, 2026
d6b6737
fix(gbrain-local-status): classifier falsely reports broken-db inside…
garrytan May 21, 2026
d709139
fix(retro): stale-base + bad-today-anchor pre-flight guard (#1624)
garrytan May 21, 2026
a05546c
test(retro): regression for #1624 stale-base pre-flight guard
garrytan May 21, 2026
700c9a4
fix(gbrain-sync): configurable timeouts + resume from gbrain checkpoi…
garrytan May 21, 2026
64a7bee
test(gbrain-sync): regression for #1611 timeouts + resume
garrytan May 21, 2026
2a51775
fix(review): pre-emit verification gate kills Django-shape FP class (…
garrytan May 21, 2026
7ec546d
test(review): regression for #1539 pre-emit verification gate
garrytan May 21, 2026
d7f474f
fix(config): expose explain_level default
jbetala7 May 19, 2026
7320f36
fix(benchmark): parse positional prompt after flags
jbetala7 May 19, 2026
b9eefbe
fix(artifacts): reject malformed remote paths
jbetala7 May 19, 2026
873799c
fix(learnings): preserve current entries in cross-project search
jbetala7 May 20, 2026
78d3052
fix(setup): register root gstack slash alias
jbetala7 May 18, 2026
07a84a0
fix(memory): probe gitleaks without shell builtin
jbetala7 May 16, 2026
5e20b41
fix(gbrain-lib): pin LC_ALL=C in varname validator (macOS locale guard)
andrey-esipov May 19, 2026
c427340
fix(land-and-deploy): detect merged PR after gh failure
davidfoy May 21, 2026
db2ed59
fix: detect PgBouncer transaction-mode pooler and set GBRAIN_PREPARE=…
mikeangstadt May 18, 2026
7ea6b1d
fix(supabase-provision): rewrite transaction/6543 -> session/5432 for…
0xDevNinja May 18, 2026
e7074b5
fix(browse): GSTACK_CHROMIUM_NO_SANDBOX opt-out for Ubuntu/AppArmor (…
techcenter68 May 21, 2026
7703f7c
fix(browse): mirror isCustomChromium() guard in headless launch()
shohu May 20, 2026
707a82e
fix(browse): daemonize macOS/Linux server via setsid()
May 19, 2026
bd3a6c6
fix(design): bump image-gen timeout to 240s + pin gpt-image-2
matteo-hertel May 18, 2026
e75a5e8
test: fill coverage gaps for PRs #1606, #1612, #1620
garrytan May 21, 2026
144327d
test(learnings): align injection-prevention tests with PR #1619 tagge…
garrytan May 21, 2026
8df2a9c
test(fixtures): regenerate ship-SKILL.md golden baselines
garrytan May 21, 2026
0ee920b
test(gbrain-detect): include gbrain_pooler_mode in schema regression …
garrytan May 21, 2026
72dac4e
chore(release): v1.43.0.0 — post-Daegu paper-cut wave
garrytan May 21, 2026
6f31954
chore(release): bump v1.43.0.0 → v1.43.2.0 for queue collision
garrytan May 21, 2026
6864012
Merge remote-tracking branch 'origin/main' into garrytan/cairo-v3
garrytan May 21, 2026
a1a46db
Merge remote-tracking branch 'origin/main' into garrytan/cairo-v3
garrytan May 22, 2026
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
93 changes: 93 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.43.1.0
1.43.2.0
15 changes: 14 additions & 1 deletion bin/gstack-artifacts-url
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ strip_git() {
echo "${1%.git}"
}

valid_owner_repo() {
local owner_repo="$1"
case "$owner_repo" in
""|/*|*/|*//*)
return 1
;;
esac
case "$owner_repo" in
*/*) return 0 ;;
*) return 1 ;;
esac
}

# Parse to (host, owner_repo) regardless of input shape.
parse_url() {
local u="$1"
Expand Down Expand Up @@ -82,7 +95,7 @@ parse_url() {
exit 3
;;
esac
if [ -z "$host" ] || [ -z "$owner_repo" ] || [ "$owner_repo" = "$u" ]; then
if [ -z "$host" ] || ! valid_owner_repo "$owner_repo"; then
echo "gstack-artifacts-url: failed to parse host/owner from: $u" >&2
exit 3
fi
Expand Down
9 changes: 5 additions & 4 deletions bin/gstack-config
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ lookup_default() {
skill_prefix) echo "false" ;;
checkpoint_mode) echo "explicit" ;;
checkpoint_push) echo "false" ;;
explain_level) echo "default" ;;
codex_reviews) echo "enabled" ;;
gstack_contributor) echo "false" ;;
skip_eng_review) echo "false" ;;
Expand Down Expand Up @@ -169,8 +170,8 @@ case "${1:-}" in
echo ""
echo "# ─── Active values (including defaults for unset keys) ───"
for KEY in proactive routing_declined telemetry auto_upgrade update_check \
skill_prefix checkpoint_mode checkpoint_push codex_reviews \
gstack_contributor skip_eng_review workspace_root \
skill_prefix checkpoint_mode checkpoint_push explain_level \
codex_reviews gstack_contributor skip_eng_review workspace_root \
artifacts_sync_mode artifacts_sync_mode_prompted; do
VALUE=$(grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true)
SOURCE="default"
Expand All @@ -185,8 +186,8 @@ case "${1:-}" in
defaults)
echo "# gstack-config defaults"
for KEY in proactive routing_declined telemetry auto_upgrade update_check \
skill_prefix checkpoint_mode checkpoint_push codex_reviews \
gstack_contributor skip_eng_review workspace_root \
skill_prefix checkpoint_mode checkpoint_push explain_level \
codex_reviews gstack_contributor skip_eng_review workspace_root \
artifacts_sync_mode artifacts_sync_mode_prompted; do
printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")"
done
Expand Down
16 changes: 15 additions & 1 deletion bin/gstack-gbrain-detect
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
* "gstack_brain_sync_mode": "off"|"artifacts-only"|"full",
* "gstack_brain_git": true|false,
* "gstack_artifacts_remote": "https://..." | "",
* "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db"
* "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db",
* "gbrain_pooler_mode": "transaction"|"session"|null
* }
*
* Backward compatibility (per plan codex #5): the 9 pre-existing fields stay
Expand All @@ -42,6 +43,7 @@ import {
resolveGbrainBin,
readGbrainVersion,
} from "../lib/gbrain-local-status";
import { isTransactionModePooler } from "../lib/gbrain-exec";

const STATE_DIR = process.env.GSTACK_HOME || join(userHome(), ".gstack");
const SCRIPT_DIR = __dirname;
Expand Down Expand Up @@ -98,6 +100,17 @@ function detectConfig(): { exists: boolean; engine: "pglite" | "postgres" | null
return { exists: true, engine: null };
}

// --- pooler mode detection (#1435) ---
//
// Reads DATABASE_URL from ~/.gbrain/config.json and checks whether it targets
// a PgBouncer transaction-mode pooler (port 6543). Surfaced so /sync-gbrain
// and /setup-gbrain can advise users when search may require GBRAIN_PREPARE.
function detectPoolerMode(): "transaction" | "session" | "unknown" | null {
const parsed = tryReadJSON(GBRAIN_CONFIG) as { database_url?: string } | null;
if (!parsed?.database_url) return null;
return isTransactionModePooler(parsed.database_url) ? "transaction" : "session";
}

// --- gbrain doctor health (any nonzero exit or non-"ok"/"warnings" status → false) ---
//
// Uses --fast to avoid hanging on a dead DB. Per the local-status classifier
Expand Down Expand Up @@ -215,6 +228,7 @@ function main(): void {
gstack_brain_git: detectBrainGit(),
gstack_artifacts_remote: detectArtifactsRemote(),
gbrain_local_status: localEngineStatus({ noCache }),
gbrain_pooler_mode: detectPoolerMode(),
};

process.stdout.write(JSON.stringify(out, null, 2) + "\n");
Expand Down
14 changes: 14 additions & 0 deletions bin/gstack-gbrain-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,22 @@
# restore), D16 (pooler URL paste hygiene with redacted preview).

# _gstack_gbrain_validate_varname <name> — returns 0 if usable, 2 otherwise.
# `local LC_ALL=C` is load-bearing twice over:
# 1. In many macOS shells the default locale (e.g. en_US.UTF-8) makes `case`
# glob brackets like `[A-Z]` match lowercase letters too. Without the
# LC_ALL=C pin, names like `lower-case` pass validation and then trip
# `printf -v "$varname"` and `export "$varname"` with "not a valid
# identifier" errors the caller can't easily distinguish from other
# failures.
# 2. `local` is required because this file is documented as a sourced helper
# (see header), so a bare `LC_ALL=C` would mutate the caller's locale for
# the rest of the process — silently affecting downstream `sort`, `tr`,
# and any locale-aware glob in the same shell.
# Together they give ASCII-only bracket semantics on both macOS and Linux
# (matching the documented `[A-Z_][A-Z0-9_]*` contract) without leaking.
_gstack_gbrain_validate_varname() {
local name="$1"
local LC_ALL=C
case "$name" in
[A-Z_][A-Z0-9_]*) return 0 ;;
*) return 2 ;;
Expand Down
18 changes: 17 additions & 1 deletion bin/gstack-gbrain-supabase-provision
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ cmd_pooler_url() {
# Prefer the singular Session Pooler config when Supabase returns an
# array (response shape can vary by project state). Fall back to the
# first PRIMARY entry if no "session" pool_mode is present.
local db_user db_host db_port db_name
local db_user db_host db_port db_name pool_mode
local first_or_session
if printf '%s' "$resp" | jq -e 'type == "array"' >/dev/null 2>&1; then
first_or_session=$(printf '%s' "$resp" | jq '[.[] | select(.pool_mode == "session")][0] // .[0]')
Expand All @@ -351,11 +351,27 @@ cmd_pooler_url() {
db_host=$(printf '%s' "$first_or_session" | jq -r '.db_host // empty')
db_port=$(printf '%s' "$first_or_session" | jq -r '.db_port // empty')
db_name=$(printf '%s' "$first_or_session" | jq -r '.db_name // empty')
pool_mode=$(printf '%s' "$first_or_session" | jq -r '.pool_mode // empty')

if [ -z "$db_user" ] || [ -z "$db_host" ] || [ -z "$db_port" ] || [ -z "$db_name" ]; then
die "pooler-url: missing pooler config fields (db_user/db_host/db_port/db_name); re-poll or check project state"
fi

# Issue #1301: New Supabase projects' Management API returns a single
# transaction-mode pooler at port 6543, but the shared pooler tenant
# for fresh projects only listens on the session port 5432. Trusting
# db_port verbatim makes `gbrain init` hang to TCP timeout (transaction
# port unreachable) before falling into "tenant not found"-style errors
# that look like auth bugs. Rewrite transaction/6543 -> session/5432.
# Override with GSTACK_SUPABASE_TRUST_API_PORT=1 if a future API version
# starts returning a working transaction port and this rewrite is wrong.
if [ "${GSTACK_SUPABASE_TRUST_API_PORT:-0}" != "1" ] \
&& [ "$pool_mode" = "transaction" ] && [ "$db_port" = "6543" ]; then
echo "pooler-url: API returned transaction pooler (port 6543); shared pooler for new projects listens on session port 5432 — rewriting (set GSTACK_SUPABASE_TRUST_API_PORT=1 to disable)" >&2
db_port=5432
pool_mode="session"
fi

local url="postgresql://${db_user}:${DB_PASS}@${db_host}:${db_port}/${db_name}"

if $json_mode; then
Expand Down
183 changes: 172 additions & 11 deletions bin/gstack-gbrain-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,115 @@ const STATE_PATH = join(GSTACK_HOME, ".gbrain-sync-state.json");
const LOCK_PATH = join(GSTACK_HOME, ".sync-gbrain.lock");
const STALE_LOCK_MS = 5 * 60 * 1000;

// Default 35-minute timeout for code-walk + memory-ingest stages. Override via
// GSTACK_SYNC_CODE_TIMEOUT_MS / GSTACK_SYNC_MEMORY_TIMEOUT_MS. Bounds-checked
// in resolveStageTimeoutMs below so wildly-low values don't make resume
// useless and wildly-high values don't mask config typos. See #1611.
const DEFAULT_STAGE_TIMEOUT_MS = 35 * 60 * 1000; // 2_100_000ms = 35min
const MIN_STAGE_TIMEOUT_MS = 60_000; // 1 minute floor
const MAX_STAGE_TIMEOUT_MS = 86_400_000; // 24 hour ceiling

/**
* Parse a stage-timeout env value with bounds validation. Returns the bounded
* value or the default with a stderr warning if the env was malformed or
* out-of-range. Exported for the regression test.
*/
export function resolveStageTimeoutMs(
envValue: string | undefined,
envName: string,
): number {
if (envValue === undefined || envValue === "") return DEFAULT_STAGE_TIMEOUT_MS;
const n = Number.parseInt(envValue, 10);
if (!Number.isFinite(n) || Number.isNaN(n) || n <= 0) {
console.warn(
`[sync] ${envName}="${envValue}" is not a positive integer; falling back to ${DEFAULT_STAGE_TIMEOUT_MS}ms`,
);
return DEFAULT_STAGE_TIMEOUT_MS;
}
if (n < MIN_STAGE_TIMEOUT_MS) {
console.warn(
`[sync] ${envName}=${n} is below the ${MIN_STAGE_TIMEOUT_MS}ms (1min) floor; falling back to ${DEFAULT_STAGE_TIMEOUT_MS}ms`,
);
return DEFAULT_STAGE_TIMEOUT_MS;
}
if (n > MAX_STAGE_TIMEOUT_MS) {
console.warn(
`[sync] ${envName}=${n} is above the ${MAX_STAGE_TIMEOUT_MS}ms (24h) ceiling; falling back to ${DEFAULT_STAGE_TIMEOUT_MS}ms`,
);
return DEFAULT_STAGE_TIMEOUT_MS;
}
return n;
}

/**
* gbrain writes ~/.gbrain/import-checkpoint.json on every import run. If a
* previous /sync-gbrain hit the timeout (SIGTERM = exit 143), the checkpoint
* + its staging dir survive on disk. Detect both and let gbrain resume from
* processedIndex+1 on the next run. If the staging dir is missing/empty/
* unreadable, fall through to a fresh restage with a one-line warning so the
* user sees we noticed. See #1611 + plan D1/C1.
*/
interface GbrainCheckpoint {
dir?: string;
totalFiles?: number;
processedIndex?: number;
completedFiles?: number;
timestamp?: string;
}

export function readGbrainCheckpoint(): GbrainCheckpoint | null {
// Read HOME from env so tests can redirect via process.env.HOME = ...
// (Node/Bun's os.homedir() caches at process start and ignores later
// mutations.)
const home = process.env.HOME || homedir();
const cpPath = join(home, ".gbrain", "import-checkpoint.json");
if (!existsSync(cpPath)) return null;
try {
const raw = readFileSync(cpPath, "utf-8");
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return null;
return parsed as GbrainCheckpoint;
} catch {
// Corrupt JSON — treat as no checkpoint and fall through to fresh restage.
return null;
}
}

export type ResumeVerdict =
| { kind: "no-checkpoint" }
| { kind: "resume"; stagingDir: string; processedIndex: number; totalFiles: number }
| { kind: "stale-staging-missing"; stagingDir: string };

/**
* Decide whether the next memory-ingest run should resume from gbrain's
* checkpoint or restage from scratch.
* - no checkpoint → run a fresh ingest pass
* - checkpoint + staging ok → resume (gbrain picks up at processedIndex+1)
* - checkpoint + staging gone → warn, fall through to fresh restage
*/
export function decideResume(): ResumeVerdict {
const cp = readGbrainCheckpoint();
if (!cp || !cp.dir) return { kind: "no-checkpoint" };
const stagingDir = cp.dir;
if (!existsSync(stagingDir)) {
return { kind: "stale-staging-missing", stagingDir };
}
// Treat "non-empty" as the safe-to-resume signal. statSync on a missing
// file throws; we already handled missing above so this is dir-level shape.
try {
const st = statSync(stagingDir);
if (!st.isDirectory()) return { kind: "stale-staging-missing", stagingDir };
} catch {
return { kind: "stale-staging-missing", stagingDir };
}
return {
kind: "resume",
stagingDir,
processedIndex: cp.processedIndex ?? 0,
totalFiles: cp.totalFiles ?? 0,
};
}

// ── CLI ────────────────────────────────────────────────────────────────────

function printUsage(): void {
Expand Down Expand Up @@ -596,28 +705,57 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
};
}

// Step 2: Run sync or reindex.
const syncArgs = args.mode === "full"
? ["reindex-code", "--source", sourceId, "--yes"]
: ["sync", "--strategy", "code", "--source", sourceId];

const syncResult = spawnGbrain(syncArgs, {
// Step 2: Always run the page-creating file walk first, then (for --full)
// a full re-embed.
//
// `gbrain reindex-code` only RE-EMBEDS pages that already exist; it never
// walks the filesystem. On a freshly-registered source (0 pages) a --full
// run that called reindex-code alone found nothing ("No code pages to
// reindex"), finished in ~1s, and left the code index permanently empty
// while still reporting OK. The page-creating walk is `sync --strategy
// code`, so --full must run it FIRST, then reindex-code, to honor the
// documented "full walk + reindex" contract for both fresh and populated
// sources.
const codeTimeoutMs = resolveStageTimeoutMs(
process.env.GSTACK_SYNC_CODE_TIMEOUT_MS,
"GSTACK_SYNC_CODE_TIMEOUT_MS",
);
const walkResult = spawnGbrain(["sync", "--strategy", "code", "--source", sourceId], {
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
timeout: 35 * 60 * 1000,
timeout: codeTimeoutMs,
baseEnv: gbrainEnv,
});

if (syncResult.status !== 0) {
if (walkResult.status !== 0) {
return {
name: "code",
ran: true,
ok: false,
duration_ms: Date.now() - t0,
summary: `gbrain ${syncArgs.join(" ")} exited ${syncResult.status}`,
summary: `gbrain sync --strategy code --source ${sourceId} exited ${walkResult.status}`,
detail: { source_id: sourceId, source_path: root, status: "failed" },
};
}

if (args.mode === "full") {
const reindexResult = spawnGbrain(["reindex-code", "--source", sourceId, "--yes"], {
stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"],
timeout: codeTimeoutMs,
baseEnv: gbrainEnv,
});

if (reindexResult.status !== 0) {
return {
name: "code",
ran: true,
ok: false,
duration_ms: Date.now() - t0,
summary: `gbrain reindex-code --source ${sourceId} exited ${reindexResult.status}`,
detail: { source_id: sourceId, source_path: root, status: "failed" },
};
}
}

// Step 3: Pin this worktree's CWD to the source via .gbrain-source. Subsequent
// gbrain code-def / code-refs / code-callers calls from anywhere under <root>
// route to this source by default — no --source flag needed.
Expand Down Expand Up @@ -745,6 +883,25 @@ function runMemoryIngest(args: CliArgs): StageResult {
return skipStageForLocalStatus("memory", localStatus, t0);
}

// Resume detection (#1611 / plan D1 + C1). If a previous run hit the
// timeout and gbrain left ~/.gbrain/import-checkpoint.json plus its staging
// dir on disk, signal the grandchild via env so it skips the prepare phase
// and lets `gbrain import` resume from processedIndex+1 against the same
// staging dir. If the staging dir is gone (disk pressure cleanup, OS
// reboot, user manual cleanup), warn and fall through to a fresh restage.
const resume = decideResume();
const childEnv = buildGbrainEnv({ announce: false });
if (resume.kind === "resume") {
console.error(
`[sync:memory] resuming from gbrain checkpoint (${resume.processedIndex}/${resume.totalFiles} files staged at ${resume.stagingDir})`,
);
childEnv.GSTACK_INGEST_RESUME_DIR = resume.stagingDir;
} else if (resume.kind === "stale-staging-missing") {
console.error(
`[sync:memory] previous checkpoint stale (staging dir ${resume.stagingDir} gone), restaging from scratch`,
);
}

const ingestPath = join(import.meta.dir, "gstack-memory-ingest.ts");
const ingestArgs = ["run", ingestPath];
if (args.mode === "full") ingestArgs.push("--bulk");
Expand All @@ -755,10 +912,14 @@ function runMemoryIngest(args: CliArgs): StageResult {
// .env.local footgun affects gstack-memory-ingest.ts too, not just the
// direct gbrain spawns in this file). The grandchild calls gbrain import
// internally and must see the DATABASE_URL from gbrain's own config.
const memoryTimeoutMs = resolveStageTimeoutMs(
process.env.GSTACK_SYNC_MEMORY_TIMEOUT_MS,
"GSTACK_SYNC_MEMORY_TIMEOUT_MS",
);
const result = spawnSync("bun", ingestArgs, {
encoding: "utf-8",
timeout: 35 * 60 * 1000,
env: buildGbrainEnv({ announce: false }),
timeout: memoryTimeoutMs,
env: childEnv,
});

// D6: parse [memory-ingest] lines from the child's stderr. ERR-prefixed
Expand Down
Loading
Loading