tamperward 2.31.1 → 2.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli/index.js +204 -89
  2. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -19715,8 +19715,10 @@ var init_record = __esm({
19715
19715
  });
19716
19716
 
19717
19717
  // src/research/run.ts
19718
+ import { randomUUID as randomUUID2 } from "node:crypto";
19718
19719
  import { execFileSync as execFileSync13 } from "node:child_process";
19719
- import { existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync23, renameSync as renameSync4, rmSync as rmSync11, writeFileSync as writeFileSync11 } from "node:fs";
19720
+ import { closeSync as closeSync3, existsSync as existsSync18, linkSync, mkdirSync as mkdirSync9, openSync as openSync3, readFileSync as readFileSync23, renameSync as renameSync4, rmSync as rmSync11, unlinkSync, writeFileSync as writeFileSync11, writeSync } from "node:fs";
19721
+ import { hostname } from "node:os";
19720
19722
  import { join as join25, resolve as resolve15 } from "node:path";
19721
19723
  function git6(args, cwd) {
19722
19724
  return execFileSync13("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
@@ -19724,6 +19726,102 @@ function git6(args, cwd) {
19724
19726
  function pairRecordPath(ledger, task, pair) {
19725
19727
  return join25(ledger, "pairs", `${task}--${pair}.json`);
19726
19728
  }
19729
+ function researchLockPath(ledger) {
19730
+ return join25(ledger, "run.lock");
19731
+ }
19732
+ function readLockOwner(path) {
19733
+ try {
19734
+ const parsed = JSON.parse(readFileSync23(path, "utf8"));
19735
+ if (!isRecord(parsed) || typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
19736
+ if (typeof parsed.host !== "string" || typeof parsed.started_at !== "string" || typeof parsed.token !== "string") return null;
19737
+ return { pid: parsed.pid, host: parsed.host, started_at: parsed.started_at, token: parsed.token };
19738
+ } catch {
19739
+ return null;
19740
+ }
19741
+ }
19742
+ function processAlive(pid) {
19743
+ try {
19744
+ process.kill(pid, 0);
19745
+ return true;
19746
+ } catch (e) {
19747
+ return e.code !== "ESRCH";
19748
+ }
19749
+ }
19750
+ function lockHolder(path) {
19751
+ const owner = readLockOwner(path);
19752
+ return owner ? `pid ${owner.pid} on ${owner.host}, started ${owner.started_at}` : "an unreadable lock file";
19753
+ }
19754
+ function acquireResearchLock(ledger, breakStaleLock = false) {
19755
+ mkdirSync9(ledger, { recursive: true });
19756
+ const path = researchLockPath(ledger);
19757
+ if (breakStaleLock && existsSync18(path)) {
19758
+ const owner2 = readLockOwner(path);
19759
+ if (!owner2) {
19760
+ throw new ResearchError(`research output ${ledger} has an unreadable lock at ${path}; verify no run is active, then remove it deliberately`);
19761
+ }
19762
+ if (owner2.host !== hostname() || processAlive(owner2.pid)) {
19763
+ throw new ResearchError(
19764
+ `research output ${ledger} is still locked by ${lockHolder(path)}; refusing to break an active lock (a reused pid looks alive: if that owner is certainly gone, remove ${path} deliberately)`
19765
+ );
19766
+ }
19767
+ const claimed = `${path}.stale-${process.pid}-${randomUUID2()}`;
19768
+ let moved = true;
19769
+ try {
19770
+ renameSync4(path, claimed);
19771
+ } catch (e) {
19772
+ if (e.code !== "ENOENT") {
19773
+ throw new ResearchError(`research output ${ledger} stale lock recovery failed for ${path}`);
19774
+ }
19775
+ moved = false;
19776
+ }
19777
+ if (moved) {
19778
+ const inspected = readLockOwner(claimed);
19779
+ if (inspected && inspected.token === owner2.token) {
19780
+ unlinkSync(claimed);
19781
+ } else {
19782
+ try {
19783
+ linkSync(claimed, path);
19784
+ unlinkSync(claimed);
19785
+ } catch {
19786
+ }
19787
+ throw new ResearchError(`research output ${ledger}: the lock at ${path} changed while it was being broken; refusing (rerun --break-lock only once no run is active)`);
19788
+ }
19789
+ }
19790
+ }
19791
+ const owner = {
19792
+ pid: process.pid,
19793
+ host: hostname(),
19794
+ started_at: (/* @__PURE__ */ new Date()).toISOString(),
19795
+ token: randomUUID2()
19796
+ };
19797
+ const text = JSON.stringify(owner) + "\n";
19798
+ let fd = -1;
19799
+ try {
19800
+ fd = openSync3(path, "wx", 384);
19801
+ writeSync(fd, text);
19802
+ closeSync3(fd);
19803
+ fd = -1;
19804
+ } catch (e) {
19805
+ if (fd !== -1) closeSync3(fd);
19806
+ if (e.code === "EEXIST") {
19807
+ throw new ResearchError(
19808
+ `research output ${ledger} is already locked by ${lockHolder(path)}; refusing concurrent writers. Verify the owner is not running, then remove ${path} deliberately or rerun with --break-lock`
19809
+ );
19810
+ }
19811
+ throw new ResearchError(`research output ${ledger} cannot create lock ${path}`);
19812
+ }
19813
+ let released = false;
19814
+ return {
19815
+ release() {
19816
+ if (released) return;
19817
+ released = true;
19818
+ try {
19819
+ if (readFileSync23(path, "utf8") === text) unlinkSync(path);
19820
+ } catch {
19821
+ }
19822
+ }
19823
+ };
19824
+ }
19727
19825
  function resumableRecord(path, expected) {
19728
19826
  let raw;
19729
19827
  try {
@@ -20012,84 +20110,46 @@ function runResearch(opts) {
20012
20110
  return 2;
20013
20111
  }
20014
20112
  const ledger = resolve15(opts.out);
20015
- const pairs = opts.pairs ?? 1;
20016
- mkdirSync9(join25(ledger, "pairs"), { recursive: true });
20017
- for (const task of tasks) {
20018
- const existingRecords = /* @__PURE__ */ new Map();
20019
- let sourceBase = null;
20020
- try {
20021
- for (let pair = 1; pair <= pairs; pair++) {
20022
- const path = pairRecordPath(ledger, task.id, pair);
20023
- if (!existsSync18(path)) continue;
20024
- const existing = resumableRecord(path, {
20025
- task: task.id,
20026
- pair,
20027
- manifest_sha256: manifestSha,
20028
- adapter: { name: adapter.name, layers: adapter.layers },
20029
- model: opts.model ?? null,
20030
- tamperward_version: TW_VERSION,
20031
- agent_argv: agentArgvIdentity,
20032
- agent_budget: opts.agentBudget ?? null,
20033
- verify_command: task.verify.command,
20034
- source_base: sourceBase
20035
- });
20036
- sourceBase ??= existing.arms.ungated.base;
20037
- if (existing.arms.ungated.base !== sourceBase) {
20038
- throw new ResearchError(
20039
- `ledger record ${path} belongs to a different source commit (${existing.arms.ungated.base.slice(0, 12)}\u2026 != ${sourceBase.slice(0, 12)}\u2026); use a new --out, or remove it deliberately`
20040
- );
20041
- }
20042
- existingRecords.set(pair, existing);
20043
- }
20044
- } catch (e) {
20045
- if (e instanceof ResearchError) {
20046
- err2(`tamperward research: ${e.message}`);
20047
- return 2;
20048
- }
20049
- throw e;
20113
+ let lock;
20114
+ try {
20115
+ lock = acquireResearchLock(ledger, opts.breakLock === true);
20116
+ } catch (e) {
20117
+ if (e instanceof ResearchError) {
20118
+ err2(`tamperward research: ${e.message}`);
20119
+ return 2;
20050
20120
  }
20051
- for (let pair = 1; pair <= pairs; pair++) {
20052
- const path = pairRecordPath(ledger, task.id, pair);
20053
- const existing = existingRecords.get(pair);
20054
- if (existing) {
20055
- if (!opts.json) out2(`tamperward research \u2014 task ${task.id} pair ${pair}: already recorded (${path}); skipping`);
20056
- continue;
20057
- }
20058
- let record;
20121
+ throw e;
20122
+ }
20123
+ try {
20124
+ const pairs = opts.pairs ?? 1;
20125
+ mkdirSync9(join25(ledger, "pairs"), { recursive: true });
20126
+ for (const task of tasks) {
20127
+ const existingRecords = /* @__PURE__ */ new Map();
20128
+ let sourceBase = null;
20059
20129
  try {
20060
- const arms = {};
20061
- for (const arm of RESEARCH_ARMS) {
20062
- if (!opts.json) out2(`tamperward research \u2014 task ${task.id} pair ${pair}: ${arm} arm`);
20063
- arms[arm] = runTrajectory(ledger, task, pair, arm, adapter, opts, sourceBase ?? void 0);
20064
- if (arm === "ungated") {
20065
- const resolvedSource = arms[arm]?.base;
20066
- if (!resolvedSource) throw new ResearchError(`task "${task.id}" pair ${pair}: ungated arm produced no source base`);
20067
- if (sourceBase !== null && resolvedSource !== sourceBase) {
20068
- throw new ResearchError(
20069
- `task "${task.id}" pair ${pair}: source base moved (${resolvedSource.slice(0, 12)}\u2026 != ${sourceBase.slice(0, 12)}\u2026)`
20070
- );
20071
- }
20072
- sourceBase ??= resolvedSource;
20130
+ for (let pair = 1; pair <= pairs; pair++) {
20131
+ const path = pairRecordPath(ledger, task.id, pair);
20132
+ if (!existsSync18(path)) continue;
20133
+ const existing = resumableRecord(path, {
20134
+ task: task.id,
20135
+ pair,
20136
+ manifest_sha256: manifestSha,
20137
+ adapter: { name: adapter.name, layers: adapter.layers },
20138
+ model: opts.model ?? null,
20139
+ tamperward_version: TW_VERSION,
20140
+ agent_argv: agentArgvIdentity,
20141
+ agent_budget: opts.agentBudget ?? null,
20142
+ verify_command: task.verify.command,
20143
+ source_base: sourceBase
20144
+ });
20145
+ sourceBase ??= existing.arms.ungated.base;
20146
+ if (existing.arms.ungated.base !== sourceBase) {
20147
+ throw new ResearchError(
20148
+ `ledger record ${path} belongs to a different source commit (${existing.arms.ungated.base.slice(0, 12)}\u2026 != ${sourceBase.slice(0, 12)}\u2026); use a new --out, or remove it deliberately`
20149
+ );
20073
20150
  }
20151
+ existingRecords.set(pair, existing);
20074
20152
  }
20075
- const ungated = arms.ungated;
20076
- const gated = arms.gated;
20077
- if (!ungated || !gated) throw new ResearchError(`task "${task.id}" pair ${pair}: an arm produced no record`);
20078
- record = {
20079
- schema_version: MACHINE_SCHEMA_VERSION,
20080
- command: "research",
20081
- document: "pair",
20082
- task: task.id,
20083
- pair,
20084
- adapter: { name: adapter.name, layers: [...adapter.layers] },
20085
- model: opts.model ?? null,
20086
- tamperward_version: TW_VERSION,
20087
- agent_argv: [...agentArgvIdentity],
20088
- agent_budget: opts.agentBudget ?? null,
20089
- manifest_sha256: manifestSha,
20090
- verify_command: task.verify.command,
20091
- arms: { ungated, gated }
20092
- };
20093
20153
  } catch (e) {
20094
20154
  if (e instanceof ResearchError) {
20095
20155
  err2(`tamperward research: ${e.message}`);
@@ -20097,20 +20157,72 @@ function runResearch(opts) {
20097
20157
  }
20098
20158
  throw e;
20099
20159
  }
20100
- const text = JSON.stringify(record);
20101
- writeRecordAtomically(path, text + "\n");
20102
- if (opts.json) {
20103
- out2(text);
20104
- } else {
20105
- const g = record.arms.gated;
20106
- const u = record.arms.ungated;
20107
- out2(
20108
- `tamperward research \u2014 task ${task.id} pair ${pair}: ungated ${u.outcome.verify_verdict} (masked=${u.outcome.masked_failure}, surviving=${u.outcome.surviving_protected_mutations}); gated ${g.outcome.verify_verdict} (masked=${g.outcome.masked_failure}, surviving=${g.outcome.surviving_protected_mutations}), tamperward ${g.treatment?.verdict ?? "n/a"} \u2192 ${g.treatment?.disposition ?? "n/a"}` + (u.measured && g.measured ? "" : `; UNMEASURABLE (${[u.unmeasurable, g.unmeasurable].filter(Boolean).join(" / ")})`) + `; recorded ${path}`
20109
- );
20160
+ for (let pair = 1; pair <= pairs; pair++) {
20161
+ const path = pairRecordPath(ledger, task.id, pair);
20162
+ const existing = existingRecords.get(pair);
20163
+ if (existing) {
20164
+ if (!opts.json) out2(`tamperward research \u2014 task ${task.id} pair ${pair}: already recorded (${path}); skipping`);
20165
+ continue;
20166
+ }
20167
+ let record;
20168
+ try {
20169
+ const arms = {};
20170
+ for (const arm of RESEARCH_ARMS) {
20171
+ if (!opts.json) out2(`tamperward research \u2014 task ${task.id} pair ${pair}: ${arm} arm`);
20172
+ arms[arm] = runTrajectory(ledger, task, pair, arm, adapter, opts, sourceBase ?? void 0);
20173
+ if (arm === "ungated") {
20174
+ const resolvedSource = arms[arm]?.base;
20175
+ if (!resolvedSource) throw new ResearchError(`task "${task.id}" pair ${pair}: ungated arm produced no source base`);
20176
+ if (sourceBase !== null && resolvedSource !== sourceBase) {
20177
+ throw new ResearchError(
20178
+ `task "${task.id}" pair ${pair}: source base moved (${resolvedSource.slice(0, 12)}\u2026 != ${sourceBase.slice(0, 12)}\u2026)`
20179
+ );
20180
+ }
20181
+ sourceBase ??= resolvedSource;
20182
+ }
20183
+ }
20184
+ const ungated = arms.ungated;
20185
+ const gated = arms.gated;
20186
+ if (!ungated || !gated) throw new ResearchError(`task "${task.id}" pair ${pair}: an arm produced no record`);
20187
+ record = {
20188
+ schema_version: MACHINE_SCHEMA_VERSION,
20189
+ command: "research",
20190
+ document: "pair",
20191
+ task: task.id,
20192
+ pair,
20193
+ adapter: { name: adapter.name, layers: [...adapter.layers] },
20194
+ model: opts.model ?? null,
20195
+ tamperward_version: TW_VERSION,
20196
+ agent_argv: [...agentArgvIdentity],
20197
+ agent_budget: opts.agentBudget ?? null,
20198
+ manifest_sha256: manifestSha,
20199
+ verify_command: task.verify.command,
20200
+ arms: { ungated, gated }
20201
+ };
20202
+ } catch (e) {
20203
+ if (e instanceof ResearchError) {
20204
+ err2(`tamperward research: ${e.message}`);
20205
+ return 2;
20206
+ }
20207
+ throw e;
20208
+ }
20209
+ const text = JSON.stringify(record);
20210
+ writeRecordAtomically(path, text + "\n");
20211
+ if (opts.json) {
20212
+ out2(text);
20213
+ } else {
20214
+ const g = record.arms.gated;
20215
+ const u = record.arms.ungated;
20216
+ out2(
20217
+ `tamperward research \u2014 task ${task.id} pair ${pair}: ungated ${u.outcome.verify_verdict} (masked=${u.outcome.masked_failure}, surviving=${u.outcome.surviving_protected_mutations}); gated ${g.outcome.verify_verdict} (masked=${g.outcome.masked_failure}, surviving=${g.outcome.surviving_protected_mutations}), tamperward ${g.treatment?.verdict ?? "n/a"} \u2192 ${g.treatment?.disposition ?? "n/a"}` + (u.measured && g.measured ? "" : `; UNMEASURABLE (${[u.unmeasurable, g.unmeasurable].filter(Boolean).join(" / ")})`) + `; recorded ${path}`
20218
+ );
20219
+ }
20110
20220
  }
20111
20221
  }
20222
+ return 0;
20223
+ } finally {
20224
+ lock.release();
20112
20225
  }
20113
- return 0;
20114
20226
  }
20115
20227
  var err2, out2;
20116
20228
  var init_run2 = __esm({
@@ -20296,6 +20408,7 @@ function parseResearchRun(args) {
20296
20408
  else if (a === "--pairs") o.pairs = Number(args[++i]);
20297
20409
  else if (a === "--model") o.model = args[++i];
20298
20410
  else if (a === "--agent-budget") o.agentBudget = Number(args[++i]);
20411
+ else if (a === "--break-lock") o.breakLock = true;
20299
20412
  else if (a === "--json") o.json = true;
20300
20413
  }
20301
20414
  return o;
@@ -20608,7 +20721,7 @@ function validateCliArgs(cmd, args) {
20608
20721
  const delimiter3 = rest.indexOf("--");
20609
20722
  const prefix = delimiter3 < 0 ? rest : rest.slice(0, delimiter3);
20610
20723
  const parsed = validateFlatArgs(prefix, {
20611
- flags: ["--json"],
20724
+ flags: ["--break-lock", "--json"],
20612
20725
  values: {
20613
20726
  "--manifest": "string",
20614
20727
  "--out": "string",
@@ -20707,9 +20820,11 @@ Formats:
20707
20820
  tamperward research run --manifest F bring-your-own-model evaluation: for every
20708
20821
  --out D --adapter A [--pairs N] task in the manifest, pin one source commit,
20709
20822
  [--model M] [--agent-budget S] clone fresh state per arm, run the agent
20710
- [--json] [-- <agent cmd...>] ungated and under the run envelope, then
20711
- observe both with verify + check. Records
20823
+ [--break-lock] [--json] ungated and under the run envelope, then
20824
+ [-- <agent cmd...>] observe both with verify + check. Records
20712
20825
  are resumable by full experiment identity.
20826
+ --break-lock recovers a verified stale
20827
+ output lock (see the research guide).
20713
20828
  tamperward research summarize --ledger D aggregate measured pairs into model behaviour,
20714
20829
  independent outcome, TamperWard hits/misses
20715
20830
  and paired counts \u2014 no composite score
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "2.31.1",
3
+ "version": "2.32.0",
4
4
  "description": "The deterministic agent-integrity gate. One ruleset, evaluated on the actual diff/commands as a verdict, enforced everywhere a change can be made.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "hexrift",