mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 19:20:26 +01:00
Merge main 1ccab0d8 into gsta258-l4-ready-receipts (rule 19 grant)
test/review-packets-removed.test.ts takes main's version (#3903 moved the review pins to test/fixtures/review-pins/). This branch's two pin entries become pin files with GSTA-202's recipe: a new replays/GST-508+GST-464+GST-967+GSTA-258-L4.json and the files/ship/sections/review-army.md.json repin to 22c37a64. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
commit
04a02bce2b
180 files changed
+3123
-433
No files matched your search
+18
-11
@@ -3,6 +3,7 @@
|
||||
* gstack-ci-carry — CI as the one full test run (GSTA-258; docs/pregate.md#ci-carry).
|
||||
*
|
||||
* gstack-ci-carry find [--base <branch>] [--profile <name>]... [--command <exact lane command> --allow-repo-commands] [--json]
|
||||
* [--policy-from head --simulate]
|
||||
* gstack-ci-carry wait [--base <branch>] [--profile <name>] [--timeout <s>] [--interval <s>]
|
||||
*
|
||||
* find: the newest verified run of each profile (default: every profile the base branch's
|
||||
@@ -14,10 +15,9 @@
|
||||
* carried, 3 still running or no run. Logic: lib/ci-carry.ts; the run checks are lib/run-evidence.ts.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { findCarry, waitForRun } from '../lib/ci-carry';
|
||||
import { isConfigUnavailable, loadConfigFromBase } from '../lib/pregate/config';
|
||||
import { findCarry, laneMapping, mappingCoverage, waitForRun } from '../lib/ci-carry';
|
||||
import { isConfigUnavailable, loadConfigFromBase, loadConfigFromHead } from '../lib/pregate/config';
|
||||
import { buildRunCheck } from '../lib/pregate/run-check';
|
||||
import { runInventory } from '../lib/run-evidence';
|
||||
import { wtreeFingerprint } from '../lib/script-invocation';
|
||||
import { resolveBaseBranch } from '../lib/ship-policy';
|
||||
|
||||
@@ -25,11 +25,13 @@ const HELP = `gstack-ci-carry — use a verified CI run at HEAD's exact tree ins
|
||||
|
||||
find [--base <branch>] [--profile <name>]... [--command <exact lane command> --allow-repo-commands] [--json]
|
||||
CI_CARRY: accepted <receipt> (exit 0) or CI_CARRY: none (<reason>) (exit 1)
|
||||
--policy-from head --simulate: the same check under the branch's own map and profiles, every line starting
|
||||
SIMULATION:; a dry run that proves a new mapping before it lands (find never writes evidence; nothing clears)
|
||||
wait [--base <branch>] [--profile <name>] [--timeout <s>] [--interval <s>]
|
||||
poll the newest run at HEAD (default 1800 s, every 20 s): CI_WAIT: green|red|running|none, then find on green;
|
||||
exit 0 carried, 1 red or not carried, 3 still running or no run
|
||||
|
||||
Profiles and ship_lanes_from come from origin/<base>'s .gstack/pregate.json only. Only a clean, committed tree
|
||||
Profiles and ship_lanes_from come from origin/<base>'s .gstack/pregate.json only (a simulation reads the branch's). Only a clean, committed tree
|
||||
carries; fork runs, stale runs, a newer red or running run on HEAD and a tree.json that misses an inventory
|
||||
file never do. Exit 2 is usage.`;
|
||||
|
||||
@@ -37,30 +39,35 @@ const argv = process.argv.slice(2);
|
||||
const cmd = argv[0];
|
||||
const all = (n: string) => argv.flatMap((a, i) => (a === n && argv[i + 1] !== undefined ? [argv[i + 1]!] : []));
|
||||
const flag = (n: string) => all(n).at(-1);
|
||||
const out = (s: string) => process.stdout.write(`${s}\n`);
|
||||
const simulate = argv.includes('--simulate');
|
||||
const out = (s: string) => process.stdout.write(`${simulate ? 'SIMULATION: ' : ''}${s}\n`);
|
||||
const usage = (m: string) => { process.stderr.write(`gstack-ci-carry: ${m}\n`); process.exit(2); };
|
||||
if (!cmd || argv.includes('--help') || argv.includes('-h')) { out(HELP); process.exit(cmd ? 0 : 2); }
|
||||
if (cmd !== 'find' && cmd !== 'wait') usage(`unknown command ${cmd}; run gstack-ci-carry --help`);
|
||||
const policyFrom = flag('--policy-from') ?? 'base';
|
||||
if (policyFrom !== 'base' && policyFrom !== 'head') usage('--policy-from needs base or head');
|
||||
if (simulate !== (policyFrom === 'head') || (simulate && cmd !== 'find')) usage('find --policy-from head --simulate go together: a branch\'s own map is only ever simulated, never carried');
|
||||
const git = (args: string[]) => { const r = spawnSync('git', args, { encoding: 'utf8', timeout: 30_000 }); return r.status === 0 ? r.stdout.trim() : ''; };
|
||||
const root = git(['rev-parse', '--show-toplevel']);
|
||||
if (!root) usage('not inside a git repository');
|
||||
const base = resolveBaseBranch(root, flag('--base'));
|
||||
const loaded = loadConfigFromBase(root, base);
|
||||
if (isConfigUnavailable(loaded) || loaded.errors.length) { out(`CI_CARRY: none (profiles_unavailable: origin/${base}'s .gstack/pregate.json is unreadable or invalid)`); process.exit(1); }
|
||||
const loaded = simulate ? loadConfigFromHead(root) : loadConfigFromBase(root, base);
|
||||
if (simulate) out(`policy ${isConfigUnavailable(loaded) ? 'unavailable' : loaded.label}; a dry run: nothing is written and nothing clears (a carry reads origin/${base}'s map only)`);
|
||||
if (isConfigUnavailable(loaded) || loaded.errors.length) { out(`CI_CARRY: none (profiles_unavailable: ${simulate ? 'the branch\'s' : `origin/${base}'s`} .gstack/pregate.json is unreadable or invalid)`); process.exit(1); }
|
||||
const command = flag('--command');
|
||||
const mapping = command !== undefined ? loaded.config.ship_lanes_from[command] : undefined;
|
||||
if (command !== undefined && !mapping) { out(`CI_CARRY: none (not_mapped: ${JSON.stringify(command)} is not a key of origin/${base}'s ship_lanes_from)`); process.exit(1); }
|
||||
const mapping = command !== undefined ? laneMapping(loaded.config.ship_lanes_from, command) : undefined;
|
||||
if (command !== undefined && !mapping) { out(`CI_CARRY: none (not_mapped: ${JSON.stringify(command)} is neither a key nor a declared alias in origin/${base}'s ship_lanes_from)`); process.exit(1); }
|
||||
if (mapping && !argv.includes('--allow-repo-commands')) usage('--command runs the mapping\'s inventory, a pin-file command: pass --allow-repo-commands');
|
||||
const profiles = all('--profile').length ? all('--profile') : mapping ? mapping.profiles : [...new Set(Object.values(loaded.config.ship_lanes_from).flatMap(m => m.profiles))];
|
||||
if (!profiles.length) { out(`CI_CARRY: none (no_profile: origin/${base} maps no lane in ship_lanes_from; pass --profile <name>)`); process.exit(1); }
|
||||
const check = buildRunCheck(root, null, argv.includes('--allow-repo-commands'), base);
|
||||
const check = buildRunCheck(root, null, argv.includes('--allow-repo-commands'), base, simulate ? 'head' : 'base');
|
||||
if ('error' in check) { out(`CI_CARRY: none (profiles_unavailable: ${check.error})`); process.exit(1); }
|
||||
const head = git(['rev-parse', 'HEAD']);
|
||||
const tree = git(['rev-parse', 'HEAD^{tree}']);
|
||||
|
||||
function find(): number {
|
||||
const wtree = () => { const fp = wtreeFingerprint({ cwd: root }); return 'tree' in fp ? fp.tree : null; };
|
||||
const v = findCarry(check as Exclude<typeof check, { error: string }>, { profiles, head, tree, wtree, ...(mapping ? { inventory: () => runInventory(mapping.inventory, root) } : {}) });
|
||||
const v = findCarry(check as Exclude<typeof check, { error: string }>, { profiles, head, tree, wtree, ...(mapping ? mappingCoverage(mapping, root) : {}) });
|
||||
if (argv.includes('--json')) out(JSON.stringify(v));
|
||||
else out(v.ok ? `CI_CARRY: accepted ${v.receipt}` : `CI_CARRY: none (${v.reason}: ${v.detail})`);
|
||||
return v.ok ? 0 : 1;
|
||||
|
||||
@@ -258,6 +258,7 @@ gstack_result_anchor() {
|
||||
DX_CAPTURE_REDACTION_FAILED) printf '%s' 'dx-capture-redaction-failed' ;;
|
||||
DX_CAPTURE_INTERNAL) printf '%s' 'dx-capture-internal' ;;
|
||||
POLICY_GSTACK_TOO_OLD) printf '%s' 'policy-gstack-too-old' ;;
|
||||
PREGATE_GSTACK_TOO_OLD) printf '%s' 'pregate-gstack-too-old' ;;
|
||||
QUEUE_NOT_READY) printf '%s' 'queue-not-ready' ;;
|
||||
QUEUE_PROOF_MISSING) printf '%s' 'queue-proof-missing' ;;
|
||||
QUEUE_STAMP_NOT_STAMP_ONLY) printf '%s' 'queue-stamp-not-stamp-only' ;;
|
||||
|
||||
+89
-24
@@ -5,7 +5,8 @@
|
||||
*
|
||||
* gstack-evidence run [--reuse [--max-age <hours>]] --label <L> -- <cmd...>
|
||||
* gstack-evidence record-ci --label <L> --base <base> -- <cmd>
|
||||
* gstack-evidence carried [--json]
|
||||
* gstack-evidence passed -- <cmd>
|
||||
* gstack-evidence carried [--base <branch>] [--json]
|
||||
* gstack-evidence check [--label <L> [--expect-cmd <exact string>]]... | --all
|
||||
* [--max-age <hours>] [--allow-paths <csv>]
|
||||
* gstack-evidence bundle --out <file> [--label <L>]... [--require <csv>] [--selection L=<text>]...
|
||||
@@ -65,8 +66,11 @@
|
||||
* `record-ci` writes the explicit CI-sourced row for a lane CI carried: it
|
||||
* finds and verifies the run itself (lib/ci-carry.ts) for a command the base's
|
||||
* ship_lanes_from maps, then records source "ci", the runs and profiles, the
|
||||
* run's finish time as ts, bound to HEAD's clean tree; `carried` lists those
|
||||
* rows at the current tree for the ship receipt.
|
||||
* run's finish time as ts, bound to HEAD's clean tree, and the policy that
|
||||
* mapped it (`origin/<base>@<sha>:…`); `carried` lists those rows at the
|
||||
* current tree for the ship receipt, only when that policy is origin/<the
|
||||
* receipt's base> (GST-1190: a row a run against another base wrote never
|
||||
* counts).
|
||||
*
|
||||
* MACHINE-LOCAL by design: neither the ledger nor the logs are brain-synced.
|
||||
* A synced record citing an unsynced log would grade FRESH on a machine where
|
||||
@@ -90,10 +94,10 @@ import { resultLine } from "../lib/result-codes";
|
||||
import { isUnavailable, loadPolicyFromBase, resolveBaseBranch } from "../lib/ship-policy";
|
||||
import { versionAtRef } from "../lib/restamp";
|
||||
import { bashCommandInvocation, runScriptSync, wtreeFingerprint } from "../lib/script-invocation";
|
||||
import { findCarry } from "../lib/ci-carry";
|
||||
import { findCarry, laneMapping, mappingCoverage, perFileCommand } from "../lib/ci-carry";
|
||||
import { runChanged } from "../lib/run-evidence";
|
||||
import { isConfigUnavailable, loadConfigFromBase } from "../lib/pregate/config";
|
||||
import { buildRunCheck } from "../lib/pregate/run-check";
|
||||
import { runInventory } from "../lib/run-evidence";
|
||||
|
||||
const BIN_DIR = dirname(Bun.fileURLToPath(import.meta.url));
|
||||
const LOG_MAX_BYTES = 2 * 1024 * 1024;
|
||||
@@ -122,10 +126,17 @@ interface EvidenceRecord {
|
||||
source?: "ci";
|
||||
runs?: string[];
|
||||
profiles?: string[];
|
||||
/** The CI jobs a jobs-mapped lane carried from (record-ci, GST-1189); tests_ran is then "n/a". */
|
||||
jobs?: string[];
|
||||
/** The base policy that mapped a carried lane (record-ci), as `origin/<base>@<sha>:.gstack/pregate.json`. */
|
||||
policy?: string;
|
||||
/** The runner's own test count (executed for bun/jest/vitest/pytest/rspec; ran with skips included for gstack's shard runner and the remote gate), 0 for ZERO-RUN, or "unknown" when no supported summary was seen. */
|
||||
tests_ran?: number | "unknown";
|
||||
tests_ran?: number | "unknown" | "n/a";
|
||||
/** "files" when a carried narrow lane's tests_ran counts its inventory files the run covered (record-ci), not tests. */
|
||||
tests_unit?: "files";
|
||||
/** Why a carried lane was not owed at all: every job it names was path-skipped by an unchanged gate (record-ci, GST-1190). */
|
||||
not_owed?: string;
|
||||
skipped_here?: { label: string; files: string[]; tests_ran: number | "unknown" };
|
||||
}
|
||||
|
||||
/** Bytes of streamed output kept for the tests_ran parse (the 2MB log would truncate the summary). */
|
||||
@@ -598,6 +609,36 @@ function identityDigest(): string {
|
||||
return sha256(JSON.stringify({ bun: version("bun"), node: version("node"), os: process.platform, arch: process.arch, image: process.env.GSTACK_RUNNER_IMAGE || null }));
|
||||
}
|
||||
|
||||
/** A local row that passed with a counted run within the max age, under this machine's identity. */
|
||||
function countedPass(r: EvidenceRecord, maxAgeHours: number): boolean {
|
||||
if (r.exit !== 0 || typeof r.tests_ran !== "number" || r.tests_ran <= 0) return false;
|
||||
const age = Date.now() - Date.parse(r.ts);
|
||||
if (!(age >= 0 && age <= maxAgeHours * 3600_000)) return false;
|
||||
return r.identity === identityDigest();
|
||||
}
|
||||
|
||||
/**
|
||||
* `passed -- <cmd>` (GST-1186, read-only): did this exact command pass here at the current working tree under any label? The
|
||||
* newest local row for the command hash and tree decides, so a newer failure vetoes; it must be a counted pass (countedPass).
|
||||
* CI rows never count: a CI pass counts only through record-ci's live check against the base. Prints PASSED: label=<L>
|
||||
* source=local ts=<ts> and exits 0, else NOT_PASSED: <reason> and exits 1. gstack-pregate's guards read it.
|
||||
*/
|
||||
function cmdPassed(argv: string[]): number {
|
||||
const sep = argv.indexOf("--");
|
||||
const command = sep < 0 ? "" : argv.slice(sep + 1).join(" ");
|
||||
if (!command || sep !== 0) { console.error("usage: gstack-evidence passed -- <command>"); return 2; }
|
||||
const wtree = currentWtree();
|
||||
if (!wtree) { console.log("NOT_PASSED: no working-tree fingerprint"); return 1; }
|
||||
let records: EvidenceRecord[] = [];
|
||||
try { records = readJsonl<EvidenceRecord>(ledgerPath().file); } catch { records = []; }
|
||||
const cmdSha = sha256(command);
|
||||
const newest = records.findLast((r) => r.cmd_sha256 === cmdSha && r.wtree === wtree && r.source === undefined);
|
||||
if (!newest) { console.log(`NOT_PASSED: no local ledger row for this command at tree ${wtree.slice(0, 12)}`); return 1; }
|
||||
if (!countedPass(newest, 24)) { console.log(`NOT_PASSED: the newest row (label=${newest.label} ts=${newest.ts}) is not a counted pass within 24 h under this runtime`); return 1; }
|
||||
console.log(`PASSED: label=${newest.label} source=local ts=${newest.reused_from ?? newest.ts}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* `run --reuse`: the newest local row for this label, command and working tree decides. A counted pass under the same
|
||||
* identity within the max age is copied (original ts kept) and printed as a run would be; anything else runs the command.
|
||||
@@ -612,10 +653,7 @@ function reused(file: string, label: string, cmdSha: string, maxAgeHours: number
|
||||
return false;
|
||||
}
|
||||
const newest = records.findLast((r) => r.label === label && r.cmd_sha256 === cmdSha && r.wtree === wtree && r.source === undefined);
|
||||
if (!newest || newest.exit !== 0 || typeof newest.tests_ran !== "number" || newest.tests_ran <= 0) return false;
|
||||
const age = Date.now() - Date.parse(newest.ts);
|
||||
if (!(age >= 0 && age <= maxAgeHours * 3600_000)) return false;
|
||||
if (newest.identity !== identityDigest()) return false;
|
||||
if (!newest || !countedPass(newest, maxAgeHours)) return false;
|
||||
try {
|
||||
appendJsonl(file, { ...newest, reused_from: newest.reused_from ?? newest.ts }, { mode: 0o600 });
|
||||
} catch (e: any) {
|
||||
@@ -629,9 +667,11 @@ function reused(file: string, label: string, cmdSha: string, maxAgeHours: number
|
||||
|
||||
/**
|
||||
* `record-ci`: carry a lane from CI. Its command must be a key of the base branch's ship_lanes_from; the run is found and
|
||||
* verified here (lib/ci-carry.ts) at HEAD's clean tree, so no caller supplies run ids, a test count or a tree. Never runs the lane.
|
||||
* verified here (lib/ci-carry.ts) at HEAD's clean tree, so no caller supplies run ids, a test count or a tree. Never runs the lane;
|
||||
* inventory files every run skipped entirely run here through the entry's per_file runner (label <L>.skipped) first, and the CI
|
||||
* row is written only when that run passes at the same tree.
|
||||
*/
|
||||
function cmdRecordCi(argv: string[]): number {
|
||||
async function cmdRecordCi(argv: string[]): Promise<number> {
|
||||
const sep = argv.indexOf("--");
|
||||
const opts = sep < 0 ? argv : argv.slice(0, sep);
|
||||
const get = (name: string) => { const i = opts.indexOf(name); return i >= 0 ? opts[i + 1] : undefined; };
|
||||
@@ -651,17 +691,33 @@ function cmdRecordCi(argv: string[]): number {
|
||||
const loaded = loadConfigFromBase(cwd, base);
|
||||
if (isConfigUnavailable(loaded)) return none("config_unavailable", loaded.detail);
|
||||
if (loaded.errors.length) return none("config_invalid", loaded.errors.map((x) => `${x.path}: ${x.message}`).join("; "));
|
||||
const from = loaded.config.ship_lanes_from;
|
||||
if (!Object.prototype.hasOwnProperty.call(from, command)) return none("not_mapped", `the command is not a key of origin/${base}'s ship_lanes_from`);
|
||||
const m = from[command]!;
|
||||
const m = laneMapping(loaded.config.ship_lanes_from, command);
|
||||
if (!m) return none("not_mapped", `the command is neither a key nor a declared alias in origin/${base}'s ship_lanes_from`);
|
||||
const check = buildRunCheck(cwd, null, true, base);
|
||||
if ("error" in check) return none("profiles_unavailable", check.error);
|
||||
const head = git(["rev-parse", "HEAD"]);
|
||||
const tree = git(["rev-parse", "HEAD^{tree}"]);
|
||||
if (!head || !tree) return none("no_head", "HEAD is unreadable");
|
||||
const wtree = () => { const fp = wtreeFingerprint({ cwd, timeoutMs: 30000 }); return "tree" in fp ? fp.tree : null; };
|
||||
const v = findCarry(check, { profiles: m.profiles, head, tree, wtree, inventory: () => runInventory(m.inventory, cwd) });
|
||||
const v = findCarry(check, { profiles: m.profiles, head, tree, wtree, ...mappingCoverage(m, cwd) });
|
||||
if (!v.ok) return none(v.reason, v.detail);
|
||||
let here: EvidenceRecord["skipped_here"];
|
||||
if (v.remainder?.length) {
|
||||
const hereLabel = `${label}.skipped`;
|
||||
console.error(`gstack-evidence record-ci: ${v.remainder.length} inventory file(s) skipped every test in CI; running them here (label=${hereLabel})`);
|
||||
const hereCommand = perFileCommand(m.per_file!, v.remainder);
|
||||
const exit = await cmdRun(["--label", hereLabel, "--", hereCommand]);
|
||||
if (exit !== 0) return none("skipped_failed", `the per_file run of ${v.remainder.length} skipped file(s) exited ${exit} (label=${hereLabel})`);
|
||||
let rows: EvidenceRecord[] = [];
|
||||
try { rows = readJsonl<EvidenceRecord>(ledgerPath().file); } catch { rows = []; }
|
||||
here = { label: hereLabel, files: v.remainder, tests_ran: rows.findLast((r) => r.label === hereLabel && r.source === undefined && r.cmd_sha256 === sha256(hereCommand) && r.wtree === tree)?.tests_ran ?? "unknown" };
|
||||
if (typeof here.tests_ran !== "number" || here.tests_ran <= 0) return none("skipped_zero_run", `the per_file run of ${v.remainder.length} skipped file(s) ran ${here.tests_ran} tests here (label=${hereLabel}); a lane passes on its runner count, never exit 0 alone`);
|
||||
for (const e of v.runs) {
|
||||
const changed = runChanged(check.deps, e.run_id, e.run_attempt);
|
||||
if (changed) return none("run_changed", changed);
|
||||
}
|
||||
if (wtree() !== tree) return none("tree_changed", `the working tree changed while the per_file run of label=${hereLabel} ran`);
|
||||
}
|
||||
let paths: ReturnType<typeof ledgerPath>;
|
||||
try {
|
||||
paths = ledgerPath();
|
||||
@@ -673,15 +729,22 @@ function cmdRecordCi(argv: string[]): number {
|
||||
const runs = v.runs.map((r) => `${r.run_id}/${r.run_attempt}`);
|
||||
const rec: EvidenceRecord = {
|
||||
ts: new Date(v.runs.map((r) => r.finished_at).sort()[0]!).toISOString(), label, command, cmd_sha256: sha256(command), exit: 0, duration_s: 0,
|
||||
commit: head, tree, dirty: false, wtree: tree, tests_ran: v.tests, source: "ci", runs, profiles: m.profiles, policy: loaded.label,
|
||||
commit: head, tree, dirty: false, wtree: tree, tests_ran: v.tests, source: "ci", runs, profiles: m.profiles, policy: loaded.label, ...(m.jobs ? { jobs: m.jobs } : {}), ...(v.unit ? { tests_unit: v.unit } : {}), ...(v.notOwed ? { not_owed: v.notOwed } : {}), ...(here ? { skipped_here: here } : {}),
|
||||
};
|
||||
appendJsonl(paths.file, rec, { mode: 0o600 });
|
||||
console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${v.tests} log=- source=ci runs=${runs.join(",")} policy=${loaded.label}`);
|
||||
console.error(`gstack-evidence: recorded label=${label} exit=0 tests_ran=${v.tests}${v.unit ? ` unit=${v.unit}` : ""} log=- source=ci runs=${runs.join(",")} policy=${loaded.label}${here ? ` skipped_here=${here.files.length} tests_ran_here=${here.tests_ran}` : ""}`);
|
||||
if (v.notOwed) console.error(`gstack-evidence: not owed: ${v.notOwed}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** `carried`: each label's newest row when it is a CI-sourced row at the current working tree, as the receipt's ci_carried entries. */
|
||||
/**
|
||||
* `carried`: each label's newest row when it is a CI-sourced row at the current working tree whose mapping policy is
|
||||
* origin/<base> (the receipt's base; default the resolved base branch), as the receipt's ci_carried entries.
|
||||
*/
|
||||
function cmdCarried(argv: string[]): number {
|
||||
const at = argv.indexOf("--base");
|
||||
if (at >= 0 && !argv[at + 1]) { console.error("usage: gstack-evidence carried [--base <branch>] [--json]"); return 2; }
|
||||
const policyRef = `origin/${(at >= 0 ? argv[at + 1]! : resolveBaseBranch(git(["rev-parse", "--show-toplevel"]) ?? process.cwd())).replace(/^origin\//, "")}@`;
|
||||
let records: EvidenceRecord[] = [];
|
||||
try {
|
||||
records = readJsonl<EvidenceRecord>(ledgerPath().file);
|
||||
@@ -691,7 +754,7 @@ function cmdCarried(argv: string[]): number {
|
||||
const wtree = currentWtree();
|
||||
const latest = new Map<string, EvidenceRecord>();
|
||||
for (const r of records) latest.set(r.label, r);
|
||||
const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs))
|
||||
const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs) && typeof r.policy === "string" && r.policy.startsWith(policyRef))
|
||||
.flatMap((r) => r.runs!.map((ref) => { const [run, attempt] = ref.split("/").map(Number); return { lane: r.label, run, attempt, tree: r.wtree! }; }));
|
||||
if (argv.includes("--json")) console.log(JSON.stringify(carried));
|
||||
else for (const c of carried) console.log(`CI_CARRIED: ${c.lane} run=${c.run}/${c.attempt} tree=${c.tree.slice(0, 12)}`);
|
||||
@@ -756,10 +819,10 @@ function cmdCheck(argv: string[]): number {
|
||||
allFresh = false;
|
||||
continue;
|
||||
}
|
||||
const testsRan = typeof latest.tests_ran === "number" ? latest.tests_ran : "unknown";
|
||||
const testsRan = typeof latest.tests_ran === "number" || latest.tests_ran === "n/a" ? latest.tests_ran : "unknown";
|
||||
const changedDuringRun = Array.isArray(latest.wtree_changed) ? latest.wtree_changed.filter((p): p is string => typeof p === "string" && p !== "" && !/[\u0000-\u001f\u007f]/.test(p)).slice(0, 5) : [];
|
||||
const ci = latest.source === "ci" && Array.isArray(latest.runs) ? ` source=ci runs=${latest.runs.filter((s) => /^\d+\/\d+$/.test(s)).join(",")}` : "";
|
||||
const detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}${ci}`;
|
||||
const detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan}${latest.tests_unit === "files" ? " unit=files" : ""} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}${ci}`;
|
||||
let verdict: "FRESH" | "STALE" | "ZERO-RUN" = "FRESH";
|
||||
let reason = "";
|
||||
if (latest.exit !== 0) {
|
||||
@@ -911,7 +974,9 @@ try {
|
||||
if (sub === "run") {
|
||||
process.exit(await cmdRun(rest));
|
||||
} else if (sub === "record-ci") {
|
||||
process.exit(cmdRecordCi(rest));
|
||||
process.exit(await cmdRecordCi(rest));
|
||||
} else if (sub === "passed") {
|
||||
process.exit(cmdPassed(rest));
|
||||
} else if (sub === "carried") {
|
||||
process.exit(cmdCarried(rest));
|
||||
} else if (sub === "check") {
|
||||
@@ -923,7 +988,7 @@ try {
|
||||
} else if (sub === "verify") {
|
||||
process.exit(cmdVerify(rest));
|
||||
} else {
|
||||
console.error("usage: gstack-evidence run|record-ci|carried|check|bundle|ancestor|verify ...");
|
||||
console.error("usage: gstack-evidence run|record-ci|passed|carried|check|bundle|ancestor|verify ...");
|
||||
process.exit(2);
|
||||
}
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -74,9 +74,10 @@ _gstack_harness_cut_block() {
|
||||
# plugins.entries.gstack-start; Hermes removes the plugin and its config entries when its
|
||||
# folder holds exactly the files gstack wrote, unedited, and is reached without a symlink
|
||||
# below the profile; otherwise it only disables it so an edit or a kept copy survives. The harness runs with PATH, HOME and the profile only. Without the harness on
|
||||
# PATH it prints the command to run instead.
|
||||
# PATH it prints the command to run instead. A profile with no harness config (openclaw.json, config.yaml) never had
|
||||
# the plugin turned on, and the harness CLI would create a config holding only that change, so it runs no harness command.
|
||||
gstack_harness_hook_release() {
|
||||
local ledger="$1" kind a b target="" on="" profile="" root="" edited=0 to="" sub cmd n=0 safe=1 total
|
||||
local ledger="$1" kind a b target="" on="" profile="" root="" edited=0 to="" sub cmd cfg n=0 safe=1 total
|
||||
[ -f "$ledger" ] || return 0
|
||||
while IFS=$'\t' read -r kind a b; do
|
||||
case "$kind" in
|
||||
@@ -91,6 +92,8 @@ gstack_harness_hook_release() {
|
||||
[ "${total:-0}" -gt "$n" ] && edited=1
|
||||
fi
|
||||
command -v timeout >/dev/null 2>&1 && to="timeout 60"
|
||||
case "$target" in openclaw) cfg=openclaw.json ;; hermes) cfg=config.yaml ;; *) cfg="" ;; esac
|
||||
if [ -z "$cfg" ] || [ -f "$profile/$cfg" ]; then
|
||||
case "$target" in
|
||||
openclaw) cmd="config unset plugins.entries.gstack-start"
|
||||
if command -v openclaw >/dev/null 2>&1 && env -i PATH="$PATH" HOME="$HOME" OPENCLAW_STATE_DIR="$profile" $to openclaw $cmd </dev/null >/dev/null 2>&1; then :
|
||||
@@ -99,6 +102,7 @@ gstack_harness_hook_release() {
|
||||
if command -v hermes >/dev/null 2>&1 && env -i PATH="$PATH" HOME="$HOME" HERMES_HOME="$profile" $to hermes plugins "$sub" gstack-start </dev/null >/dev/null 2>&1; then :
|
||||
else echo " start hook: turn it off in Hermes yourself: HERMES_HOME=\"$profile\" hermes plugins disable gstack-start"; fi ;;
|
||||
esac
|
||||
fi
|
||||
if [ "$safe" = 1 ] && [ -d "$root/__pycache__" ] && _gstack_harness_owned "$root/__pycache__" "$root"; then
|
||||
rm -f "$root"/__pycache__/*.pyc
|
||||
rmdir "$root/__pycache__" 2>/dev/null
|
||||
|
||||
+55
-8
@@ -8,8 +8,11 @@
|
||||
# shares them.
|
||||
#
|
||||
# Usage:
|
||||
# gstack-lease touch [--root <checkout>] [--pid <p> | --from <pid>]
|
||||
# write <git dir>/gstack-leases/<pid> holding the pid's start identity;
|
||||
# gstack-lease touch [--root <checkout>] [--pid <p> | --from <pid>] [--run <id>]
|
||||
# write <git dir>/gstack-leases/<pid> holding the pid's start identity
|
||||
# and the open run <id> (gstack-skill-start's SESSION_ID); without --run
|
||||
# it records a hold no release closes; a run counts 120 min from its
|
||||
# start and a hold 120 min from its last renewal;
|
||||
# pid defaults to the harness pid behind this call
|
||||
# (bin/gstack-harness-pid.sh, the same derivation the session marker
|
||||
# uses), or behind --from (a caller whose own parent is short-lived
|
||||
@@ -24,8 +27,11 @@
|
||||
# --count-shared-harness (bin/gstack-upgrade only) still prints an
|
||||
# ancestor that is a shared harness (capy-server: every session on the
|
||||
# machine runs under it) while its lease is under 120 minutes old
|
||||
# gstack-lease release --pid <p> --run <id> [--root <checkout>]
|
||||
# close run <id> on <pid>'s lease (gstack-skill-end); once every run it
|
||||
# recorded is closed the lease is idle and live skips it (GST-1205)
|
||||
# gstack-lease list [--root <checkout>]
|
||||
# print `<pid> <age>m alive|dead|stale` for every lease
|
||||
# print `<pid> <age>m alive|idle|dead|stale` for every lease
|
||||
# A pid alone is not a holder: pids are reused after a reboot or a machine
|
||||
# wake. A lease records its process's start identity (Linux: boot id and
|
||||
# /proc start time; elsewhere `ps -o lstart`), and a lease whose pid now
|
||||
@@ -47,12 +53,13 @@ BIN="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
}
|
||||
|
||||
VERB="${1:-}"; [ $# -gt 0 ] && shift
|
||||
ROOT="" PID="" EXCLUDE="" FROM="" SHARED=0
|
||||
ROOT="" PID="" EXCLUDE="" FROM="" SHARED=0 RUN=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--root) ROOT="${2:-}"; shift ;;
|
||||
--pid) PID="${2:-}"; shift ;;
|
||||
--from) FROM="${2:-}"; shift ;;
|
||||
--run) RUN="${2:-}"; shift ;;
|
||||
--exclude-ancestry-of) EXCLUDE="${2:-}"; shift ;;
|
||||
--count-shared-harness) SHARED=1 ;;
|
||||
*) echo "gstack-lease: unknown argument '$1'" >&2; exit 2 ;;
|
||||
@@ -60,8 +67,8 @@ while [ $# -gt 0 ]; do
|
||||
shift
|
||||
done
|
||||
case "$VERB" in
|
||||
touch|live|list) ;;
|
||||
-h|--help|'') sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
touch|live|list|release) ;;
|
||||
-h|--help|'') sed -n '2,34p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "gstack-lease: unknown verb '$VERB'" >&2; exit 2 ;;
|
||||
esac
|
||||
[ -n "$ROOT" ] || ROOT=$(gstack_source_root "$BIN")
|
||||
@@ -248,6 +255,15 @@ reclaim() {
|
||||
unlock
|
||||
return 1
|
||||
}
|
||||
# Run entries (GST-1205), lines after the identity: `run <id> <epoch>` per
|
||||
# open run, `hold <epoch>` for a holder that opened none, `idle` once every
|
||||
# run closed. An entry older than 120 minutes no longer counts.
|
||||
# fresh FILE [DROP] — the entries under 120 minutes old, minus the line whose
|
||||
# first two fields are DROP ("run <id>" or "hold").
|
||||
fresh() { sed -n '2,$p' "$1" 2>/dev/null | awk -v now="$(date +%s)" -v drop="${2:-}" '($1 == "run" && NF == 3 && now - $3 < 7200 && $1 " " $2 != drop) || ($1 == "hold" && NF == 2 && now - $2 < 7200 && $1 != drop)'; }
|
||||
# idle FILE — the lease records entries and none of them still counts. A lease
|
||||
# with no entries (written without --run, or by an older gstack) is never idle.
|
||||
idle() { [ -n "$(sed -n '2,$p' "$1" 2>/dev/null | grep -v '^$')" ] && [ -z "$(fresh "$1")" ]; }
|
||||
age_min() { # FILE — minutes since its mtime
|
||||
_m=$(stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0)
|
||||
echo $(( ( $(date +%s) - _m ) / 60 ))
|
||||
@@ -264,7 +280,20 @@ case "$VERB" in
|
||||
exit 3
|
||||
}
|
||||
_tmp="$DIR/.$PID.$$"
|
||||
identity "$PID" > "$_tmp" && mv -f "$_tmp" "$DIR/$PID" || { rm -f "$_tmp"; unlock; exit 2; }
|
||||
_id=$(identity "$PID") _keep="" _held=0 _now=$(date +%s)
|
||||
if [ -f "$DIR/$PID" ] && [ "$(head -1 "$DIR/$PID" 2>/dev/null)" = "$_id" ]; then
|
||||
if [ -n "$RUN" ]; then _keep=$(fresh "$DIR/$PID" "run $RUN"); else _keep=$(fresh "$DIR/$PID" hold); fi
|
||||
[ -z "$(sed -n '2,$p' "$DIR/$PID" 2>/dev/null | grep -v '^$')" ] && _held=1
|
||||
fi
|
||||
{
|
||||
printf '%s\n' "$_id"
|
||||
[ -z "$_keep" ] || printf '%s\n' "$_keep"
|
||||
if [ -n "$RUN" ]; then
|
||||
[ "$_held" -eq 1 ] && echo "hold $(stat -c %Y "$DIR/$PID" 2>/dev/null || stat -f %m "$DIR/$PID" 2>/dev/null || echo "$_now")"
|
||||
echo "run $RUN $_now"
|
||||
elif [ -n "$_keep" ]; then echo "hold $_now"
|
||||
fi
|
||||
} > "$_tmp" && mv -f "$_tmp" "$DIR/$PID" || { rm -f "$_tmp"; unlock; exit 2; }
|
||||
find "$DIR" -maxdepth 1 -mmin +120 -type f ! -name '.lock*' -exec rm -f {} + 2>/dev/null
|
||||
for _c in "$DIR"/.lock*.claim.*; do
|
||||
[ -f "$_c" ] || continue
|
||||
@@ -274,12 +303,29 @@ case "$VERB" in
|
||||
rm -rf "$DIR"/.lock*.gone.* 2>/dev/null
|
||||
unlock
|
||||
exit 0 ;;
|
||||
release)
|
||||
case "$PID" in ''|*[!0-9]*) echo "gstack-lease: release needs --pid <number> and --run <id>" >&2; exit 2 ;; esac
|
||||
[ -n "$RUN" ] && [ "$RUN" != - ] || { echo "gstack-lease: release needs --pid <number> and --run <id>" >&2; exit 2; }
|
||||
[ -f "$DIR/$PID" ] || exit 0
|
||||
lock || exit 3
|
||||
f="$DIR/$PID" _tmp="$DIR/.$PID.$$"
|
||||
if sed -n '2,$p' "$f" | awk -v id="$RUN" '$1 == "run" && $2 == id { found = 1 } END { exit !found }'; then
|
||||
_keep=$(fresh "$f" "run $RUN")
|
||||
{
|
||||
head -1 "$f"
|
||||
if [ -n "$_keep" ]; then printf '%s\n' "$_keep"; else echo idle; fi
|
||||
} > "$_tmp" && touch -r "$f" "$_tmp" && mv -f "$_tmp" "$f" || { rm -f "$_tmp"; unlock; exit 2; }
|
||||
fi
|
||||
unlock
|
||||
exit 0 ;;
|
||||
list)
|
||||
for f in "$DIR"/*; do
|
||||
[ -f "$f" ] || continue
|
||||
p=${f##*/}
|
||||
_age=$(age_min "$f")
|
||||
echo "$p ${_age}m $(state "$p" "$(head -1 "$f" 2>/dev/null)" "$_age")"
|
||||
_st=$(state "$p" "$(head -1 "$f" 2>/dev/null)" "$_age")
|
||||
[ "$_st" = alive ] && idle "$f" && _st=idle
|
||||
echo "$p ${_age}m $_st"
|
||||
done
|
||||
exit 0 ;;
|
||||
esac
|
||||
@@ -296,6 +342,7 @@ done
|
||||
FOUND=1
|
||||
for f in "$DIR"/*; do
|
||||
[ -f "$f" ] || continue
|
||||
idle "$f" && continue
|
||||
p=${f##*/}
|
||||
_age=$(age_min "$f")
|
||||
case "$ANCESTRY" in *" $p "*) [ "$SHARED" -eq 1 ] && [ "$_age" -lt 120 ] && gstack_shared_harness "$p" || continue ;; esac
|
||||
|
||||
+35
-14
@@ -187,9 +187,9 @@ function reviewedProduct(repoRoot: string, base: string, head: string, pregateFi
|
||||
return typeof id === 'string' ? { head_tree: tree, product_patch_id: id || 'none' } : { head_tree: tree };
|
||||
}
|
||||
|
||||
/** The lanes CI carried at the checked-out tree, from this machine's evidence ledger (gstack-evidence carried); undefined when none. */
|
||||
function ciCarried(): ShipReceipt['ci_carried'] {
|
||||
const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-evidence'), ['carried', '--json'], { encoding: 'utf8', timeout: 60_000 });
|
||||
/** The lanes CI carried at the checked-out tree under origin/<base>'s mapping, from this machine's evidence ledger (gstack-evidence carried); undefined when none. */
|
||||
function ciCarried(base: string): ShipReceipt['ci_carried'] {
|
||||
const r = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-evidence'), ['carried', '--base', base, '--json'], { encoding: 'utf8', timeout: 60_000 });
|
||||
try { const list = JSON.parse(r.stdout); return Array.isArray(list) && list.length ? list : undefined; } catch { return undefined; }
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ function cmdWrite(rest: string[], json: boolean): number {
|
||||
version: flag(rest, '--version') ?? receiptVersion(repoRoot, head, base), base,
|
||||
gated_tree: flag(rest, '--gated-tree') ?? tree?.gated_tree, predecessor: flag(rest, '--predecessor') ?? tree?.predecessor?.recorded_head ?? undefined,
|
||||
tree: tree?.tree, gate_reuse: tree ? `${tree.gate_reuse} (${tree.gate_reuse_reason})` : undefined,
|
||||
gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: ciCarried(),
|
||||
gate: jsonArg(flag(rest, '--gate')), ci: jsonArg(flag(rest, '--ci')), ci_carried: ciCarried(base),
|
||||
spend_usd: flag(rest, '--spend-usd') !== undefined ? (Number.isFinite(Number(flag(rest, '--spend-usd'))) ? Number(flag(rest, '--spend-usd')) : flag(rest, '--spend-usd')) : undefined,
|
||||
session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'),
|
||||
policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'),
|
||||
@@ -520,13 +520,13 @@ function verifiedOutputs(repoRoot: string, base: string, pregateFile: string | u
|
||||
return regen.proven.filter(p => !/[*?[\]\\]/.test(p) && matchesAny(owned, p));
|
||||
}
|
||||
|
||||
interface ProductRules { extra: { metadata: string[]; outputs: readonly string[]; fragments?: string }; metadata: string[]; templates: readonly string[] }
|
||||
interface ProductRules { extra: { metadata: string[]; outputs: readonly string[]; fragments?: string }; metadata: string[]; templates: readonly string[]; placeholders: readonly string[] }
|
||||
function productRules(repoRoot: string, base: string, outputs: readonly string[]): ProductRules {
|
||||
const load = loadPolicyFromBase(repoRoot, base);
|
||||
const policy: ShipPolicy | null = isUnavailable(load) ? null : load.policy;
|
||||
const fragments = policy?.changelog_fragments?.dir.replace(/^(\.\/)+/, '').replace(/\/+$/, '');
|
||||
const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])], outputs, fragments };
|
||||
return { extra, metadata: [...CARRY_METADATA_GLOBS, ...extra.metadata, ...(fragments ? [`${fragments}/*.md`] : []), ...outputs], templates: policy?.stamp_patterns ?? [] };
|
||||
return { extra, metadata: [...CARRY_METADATA_GLOBS, ...extra.metadata, ...(fragments ? [`${fragments}/*.md`] : []), ...outputs], templates: policy?.stamp_patterns ?? [], placeholders: policy?.stamp_placeholders ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -553,7 +553,7 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st
|
||||
const versions = new Map<string, string | null>();
|
||||
const version = (ref: string) => { if (!versions.has(ref)) versions.set(ref, versionAt(ref)); return versions.get(ref)!; };
|
||||
const stamped = (p: string) => matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p);
|
||||
const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates, side: a === from && b === to ? branch?.own(p) : undefined } : undefined);
|
||||
const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates, placeholders: rules.placeholders, side: a === from && b === to ? branch?.own(p) : undefined } : undefined);
|
||||
const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classify(p, from, to) }));
|
||||
const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`);
|
||||
const demoted = !branch ? changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path)
|
||||
@@ -569,7 +569,7 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st
|
||||
const was = before.split('\n');
|
||||
const now = after.split('\n');
|
||||
const lines = was.map((_, i) => i).filter(i => was[i] !== now[i]);
|
||||
return was.length === now.length && lines.length > 0 && lines.every(i => side.at.has(i + 1) && stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, rules.templates, side, i + 1));
|
||||
return was.length === now.length && lines.length > 0 && lines.every(i => side.at.has(i + 1) && stampOnlyLine(was[i]!, now[i]!, oldVersion, newVersion, rules.templates, side, i + 1, rules.placeholders));
|
||||
});
|
||||
return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)], demoted, ownRows };
|
||||
}
|
||||
@@ -680,11 +680,19 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
// receipt written against the base while stacked reviewed the parent's hunks too, and the squash moved them into the
|
||||
// base (GST-1199). A parent counts only when merging it into the current base changes no product path.
|
||||
const measure = (rangeBase: string) => {
|
||||
const listed = [touchedPaths(repoRoot, rangeBase, from), touchedPaths(repoRoot, headBase, head)];
|
||||
if (!listed[0] || !listed[1]) return `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`;
|
||||
const touched = new Set([...listed[0], ...listed[1]]);
|
||||
const spec = productSpec(repoRoot, rules, from, head, { touched, sides: [[rangeBase, from], [headBase, head]], own: branchSides(repoRoot, baseRef, from, '') });
|
||||
if (!spec) return `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`;
|
||||
const listed = [touchedPaths(repoRoot, rangeBase, from), touchedPaths(repoRoot, headBase, head), rangeBase === fromBase ? new Set<string>() : touchedPaths(repoRoot, fromBase, from)];
|
||||
if (!listed[0] || !listed[1] || !listed[2]) return `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`;
|
||||
// From a stack parent, a path whose reviewed content is the base's (an earlier base merge brought it in) is base
|
||||
// context, not reviewed product, unless HEAD changed it against the base (GST-1214).
|
||||
// A pathspec also names everything beneath a path, so a path with any compared path under it (a file the base turned
|
||||
// into a directory) stays in.
|
||||
const compared = [...listed[0], ...listed[1]];
|
||||
const baseMerged = rangeBase === fromBase ? [] : [...listed[0]].filter(p => !listed[2]!.has(p) && !listed[1]!.has(p) && !compared.some(q => q.startsWith(`${p}/`)));
|
||||
const touched = new Set([...listed[0], ...listed[1]].filter(p => !baseMerged.includes(p)));
|
||||
const found = productSpec(repoRoot, rules, from, head, { touched, sides: [[rangeBase, from], [headBase, head]], own: branchSides(repoRoot, baseRef, from, '') });
|
||||
if (!found) return `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`;
|
||||
const skip = baseMerged.map(p => `:(exclude,literal)${p}`);
|
||||
const spec = { ...found, product: [...found.product, ...skip], tests: [...found.tests, ...skip] };
|
||||
const ids = [patchId(repoRoot, rangeBase, from, spec.product), patchId(repoRoot, headBase, head, spec.product)];
|
||||
for (const r of ids) if (typeof r !== 'string') return `git could not compute the product patch-id at one of the heads: ${r.error}`;
|
||||
return { rangeBase, touched, spec, productFrom: ids[0] as string, productHead: ids[1] as string };
|
||||
@@ -733,6 +741,16 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
if (changedProduct.length >= CARRY_NAMED_PATHS) { walkCut = true; break; }
|
||||
const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra);
|
||||
if (cls === 'metadata' && spec.ownRows.includes(p) && !movedBase.has(p)) stampOnly.push(p);
|
||||
if (cls === 'metadata' && spec.demoted.includes(p) && movedBase.has(p) && /(^|\/)package\.json$/.test(p)) {
|
||||
const masked = Object.values(sides).map(ref => {
|
||||
const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
try { const { version: _, ...rest } = JSON.parse(r.status === 0 ? r.stdout : 'null') ?? {}; return JSON.stringify(rest); } catch { return null; }
|
||||
});
|
||||
const [rb, rv, cb, cv] = masked;
|
||||
if (masked.some(m => m === null) || rv !== cv || rb !== cb) changedProduct.push(p);
|
||||
else stampOnly.push(p);
|
||||
continue;
|
||||
}
|
||||
if (cls !== 'product') continue;
|
||||
const verdict = comparePath(repoRoot, sides, p);
|
||||
if (verdict === 'changed') changedProduct.push(p);
|
||||
@@ -836,7 +854,10 @@ function cmdCarry(rest: string[], json: boolean): number {
|
||||
depthField = { review_depth: { ...now, depth: deeper(receipt.review_depth.depth, now.depth) }, review_exclude: receiptExclude(review.exclude) };
|
||||
}
|
||||
// The rebound product_patch_id is HEAD's diff against the carry's base, so a retargeted receipt names that base from now on.
|
||||
const rebound = { ...receipt, ...(retarget ? { base: base.replace(/^origin\//, '') } : {}), ...(pregateField ?? {}), ...depthField };
|
||||
// A receipt that records a version names HEAD's, read as write reads it: a restamp after a base merge moves it (GST-1030).
|
||||
// A head with no version source keeps the recorded one (write --version named it).
|
||||
const version = receipt.version === undefined ? undefined : (receiptVersion(repoRoot, head, base) ?? receipt.version);
|
||||
const rebound = { ...receipt, ...(version !== undefined ? { version } : {}), ...(retarget ? { base: base.replace(/^origin\//, '') } : {}), ...(pregateField ?? {}), ...depthField };
|
||||
const carried = carryReceipt(rebound, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch });
|
||||
const errors = validateReceipt(carried);
|
||||
if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`);
|
||||
|
||||
@@ -57,6 +57,9 @@ _TEL_END=$(date +%s)
|
||||
case "$TEL_START" in ''|*[!0-9]*) TEL_START="$_TEL_END" ;; esac
|
||||
_TEL_DUR=$(( _TEL_END - TEL_START ))
|
||||
[ -n "$SESSION_ID" ] && rm -f "$_GH/analytics/.pending-$SESSION_ID" 2>/dev/null || true
|
||||
# Release the workflow lease run gstack-skill-start opened (SESSION_ID starts
|
||||
# with its harness pid), so a finished run stops blocking gstack-upgrade (GST-1205).
|
||||
[ -n "$SESSION_ID" ] && "$_BIN/gstack-lease" release --pid "${SESSION_ID%%-*}" --run "$SESSION_ID" >/dev/null 2>&1 || true
|
||||
|
||||
# Record completion before the final drain, including its durable enqueue.
|
||||
# The normal sync gates still decide publication: full may sync timelines,
|
||||
|
||||
+11
-9
@@ -127,13 +127,6 @@ fi
|
||||
mkdir -p "$_GH/sessions" 2>/dev/null || true
|
||||
touch "$_GH/sessions/$PARENT_PID" 2>/dev/null || true
|
||||
find "$_GH/sessions" -mmin +120 -type f -exec rm {} + 2>/dev/null || true
|
||||
# The install-scoped twin (bin/gstack-lease): what bin/gstack-upgrade reads
|
||||
# before moving a checkout other sessions share. Fail-open, but a lease the
|
||||
# held lock kept from being written is reported.
|
||||
"$_BIN/gstack-lease" touch --pid "$PARENT_PID" >/dev/null 2>&1 || {
|
||||
[ $? -eq 3 ] && echo "gstack-lease: the lease directory stayed locked by a live process; this session holds no workflow lease, so bin/gstack-upgrade cannot see it" >&2
|
||||
true
|
||||
}
|
||||
_SESSIONS=$(find "$_GH/sessions" -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
_PROACTIVE=$("$_BIN/gstack-config" get proactive 2>/dev/null || echo "core")
|
||||
@@ -241,9 +234,18 @@ _TEL_PROMPTED=$([ -f "$_GH/.telemetry-prompted" ] && echo "yes" || echo "no")
|
||||
_TEL_START=$(date +%s)
|
||||
# PID+epoch alone is guessable; a random suffix makes the block-binding token
|
||||
# unforgeable by content reflected into the same tool result. Falls back to
|
||||
# the plain form when urandom is unavailable.
|
||||
# this process's pid when urandom is unavailable, so two same-second starts
|
||||
# never share a SESSION_ID (it also keys their lease runs, GST-1205).
|
||||
_SID_RAND=$(od -An -N4 -tx4 /dev/urandom 2>/dev/null | tr -d ' \n' || true)
|
||||
_SESSION_ID="$PARENT_PID-$_TEL_START${_SID_RAND:+-$_SID_RAND}"
|
||||
_SESSION_ID="$PARENT_PID-$_TEL_START-${_SID_RAND:-$$}"
|
||||
# The install-scoped twin (bin/gstack-lease): what bin/gstack-upgrade reads
|
||||
# before moving a checkout other sessions share. This run holds it until
|
||||
# gstack-skill-end releases its SESSION_ID (GST-1205). Fail-open, but a lease
|
||||
# the held lock kept from being written is reported.
|
||||
"$_BIN/gstack-lease" touch --pid "$PARENT_PID" --run "$_SESSION_ID" >/dev/null 2>&1 || {
|
||||
[ $? -eq 3 ] && echo "gstack-lease: the lease directory stayed locked by a live process; this session holds no workflow lease, so bin/gstack-upgrade cannot see it" >&2
|
||||
true
|
||||
}
|
||||
echo "TELEMETRY: ${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
echo "SESSION_ID: $_SESSION_ID"
|
||||
|
||||
Reference in new issue
Block a user