patchwork-os 0.2.0-beta.4 → 0.2.0-beta.5

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.
package/dist/index.js CHANGED
@@ -193,8 +193,12 @@ if (process.argv[2] === "--help" ||
193
193
  ` recipe --help Full recipe subcommand index\n\n` +
194
194
  `Diagnose\n` +
195
195
  ` halts [--window 1h|24h|overnight|7d] Morning summary of recent recipe halts\n` +
196
+ ` judgments [--window ...] [--recipe N] Recent judge-step verdicts across runs\n` +
196
197
  ` traces export Bundle approval / recipe / decision traces\n` +
197
198
  ` print-token [--port N] Print the active bridge auth token\n\n` +
199
+ `Safety\n` +
200
+ ` kill-switch <engage|release|status> Block / resume write-tier tools across bridges\n` +
201
+ ` panic [--reason "..."] Shorthand for kill-switch engage\n\n` +
198
202
  `Daemon (no subcommand)\n` +
199
203
  ` --workspace <dir> Start the bridge in foreground\n` +
200
204
  ` --watch Auto-restart supervisor\n` +
@@ -1763,11 +1767,22 @@ if (process.argv[2] === "kill-switch") {
1763
1767
  // form is `kill-switch engage`; this alias matches it so shell history six
1764
1768
  // months later still makes sense. Does not accept sub-verbs — just runs engage.
1765
1769
  if (process.argv[2] === "panic") {
1770
+ const extra = process.argv.slice(3); // e.g. --reason "..." --force-local
1771
+ // Guard against `panic --help` engaging the kill switch — a real
1772
+ // footgun if you tab-completed the verb to confirm syntax before
1773
+ // committing to the action. `panic` is an alias, so we honor --help
1774
+ // here ourselves rather than forwarding to kill-switch engage.
1775
+ if (extra.includes("--help") || extra.includes("-h")) {
1776
+ console.log('Usage: patchwork panic [--reason "..."] [--force-local]\n\n' +
1777
+ " Alias for `patchwork kill-switch engage` — blocks all write-tier\n" +
1778
+ " tool calls across every running bridge. Use --reason to leave a\n" +
1779
+ " note in the audit trail. Release with `patchwork kill-switch release`.\n");
1780
+ process.exit(0);
1781
+ }
1766
1782
  // Spawn self with kill-switch engage to reuse the full handler without
1767
1783
  // duplicating 200+ LOC. Passes through any flags (--reason, --force-local).
1768
1784
  import("node:child_process").then(({ spawnSync }) => {
1769
1785
  const self = process.argv[1] ?? process.execPath;
1770
- const extra = process.argv.slice(3); // e.g. --reason "..." --force-local
1771
1786
  const result = spawnSync(process.execPath, [self, "kill-switch", "engage", ...extra], { stdio: "inherit" });
1772
1787
  process.exit(result.status ?? 1);
1773
1788
  });
@@ -1834,14 +1849,6 @@ if (process.argv[2] === "halts") {
1834
1849
  process.stderr.write("No running bridge found. Start one with `patchwork start` (or `--driver subprocess`).\n");
1835
1850
  process.exit(2);
1836
1851
  }
1837
- // Single-bridge default: query the first. Multi-bridge users will
1838
- // typically have one orchestrator anyway; expanding to fan-out is a
1839
- // follow-up if needed.
1840
- const lock = liveLocks[0];
1841
- if (!lock) {
1842
- process.stderr.write("No running bridge found.\n");
1843
- process.exit(2);
1844
- }
1845
1852
  const sinceMs = windowSinceMs(window);
1846
1853
  const params = [];
1847
1854
  if (sinceMs != null)
@@ -1849,20 +1856,35 @@ if (process.argv[2] === "halts") {
1849
1856
  if (recipeFilter)
1850
1857
  params.push(`recipe=${encodeURIComponent(recipeFilter)}`);
1851
1858
  const qs = params.length > 0 ? `?${params.join("&")}` : "";
1852
- const controller = new AbortController();
1853
- const timer = setTimeout(() => controller.abort(), 10_000);
1854
- let res;
1855
- try {
1856
- res = await fetch(`http://127.0.0.1:${lock.port}/runs/halt-summary${qs}`, {
1857
- headers: { Authorization: `Bearer ${lock.authToken}` },
1858
- signal: controller.signal,
1859
- });
1860
- }
1861
- finally {
1862
- clearTimeout(timer);
1859
+ // Walk live bridges in order; first responsive one wins. See the
1860
+ // matching block in the `judgments` handler — findAllLiveBridges
1861
+ // can include stale entries when a recycled PID still answers
1862
+ // `kill(pid, 0)` but the lock points at a dead bridge.
1863
+ let res = null;
1864
+ let lastStatus = 0;
1865
+ for (const lock of liveLocks) {
1866
+ const controller = new AbortController();
1867
+ const timer = setTimeout(() => controller.abort(), 10_000);
1868
+ try {
1869
+ const candidate = await fetch(`http://127.0.0.1:${lock.port}/runs/halt-summary${qs}`, {
1870
+ headers: { Authorization: `Bearer ${lock.authToken}` },
1871
+ signal: controller.signal,
1872
+ });
1873
+ if (candidate.ok) {
1874
+ res = candidate;
1875
+ break;
1876
+ }
1877
+ lastStatus = candidate.status;
1878
+ }
1879
+ catch {
1880
+ /* unreachable lock — try next */
1881
+ }
1882
+ finally {
1883
+ clearTimeout(timer);
1884
+ }
1863
1885
  }
1864
- if (!res.ok) {
1865
- process.stderr.write(`Bridge returned ${res.status} for /runs/halt-summary\n`);
1886
+ if (!res) {
1887
+ process.stderr.write(`No live bridge served /runs/halt-summary (last status: ${lastStatus || "unreachable"}).\n`);
1866
1888
  process.exit(1);
1867
1889
  }
1868
1890
  const summary = (await res.json());
@@ -1974,11 +1996,6 @@ if (process.argv[2] === "judgments") {
1974
1996
  process.stderr.write("No running bridge found. Start one with `patchwork start` (or `--driver subprocess`).\n");
1975
1997
  process.exit(2);
1976
1998
  }
1977
- const lock = liveLocks[0];
1978
- if (!lock) {
1979
- process.stderr.write("No running bridge found.\n");
1980
- process.exit(2);
1981
- }
1982
1999
  const sinceMs = windowSinceMs(window);
1983
2000
  const params = [];
1984
2001
  if (sinceMs != null)
@@ -1986,20 +2003,37 @@ if (process.argv[2] === "judgments") {
1986
2003
  if (recipeFilter)
1987
2004
  params.push(`recipe=${encodeURIComponent(recipeFilter)}`);
1988
2005
  const qs = params.length > 0 ? `?${params.join("&")}` : "";
1989
- const controller = new AbortController();
1990
- const timer = setTimeout(() => controller.abort(), 10_000);
1991
- let res;
1992
- try {
1993
- res = await fetch(`http://127.0.0.1:${lock.port}/runs/judge-summary${qs}`, {
1994
- headers: { Authorization: `Bearer ${lock.authToken}` },
1995
- signal: controller.signal,
1996
- });
1997
- }
1998
- finally {
1999
- clearTimeout(timer);
2006
+ // Walk live bridges in order; the first responsive one wins.
2007
+ // findAllLiveBridges uses `kill(pid, 0)` for liveness, which
2008
+ // returns true for any recycled PID — so liveLocks can contain
2009
+ // stale entries from dead bridges. Previously we picked [0]
2010
+ // unconditionally and surfaced a confusing 404; now we try each
2011
+ // and only fall through to the error path when *all* fail.
2012
+ let res = null;
2013
+ let lastStatus = 0;
2014
+ for (const lock of liveLocks) {
2015
+ const controller = new AbortController();
2016
+ const timer = setTimeout(() => controller.abort(), 10_000);
2017
+ try {
2018
+ const candidate = await fetch(`http://127.0.0.1:${lock.port}/runs/judge-summary${qs}`, {
2019
+ headers: { Authorization: `Bearer ${lock.authToken}` },
2020
+ signal: controller.signal,
2021
+ });
2022
+ if (candidate.ok) {
2023
+ res = candidate;
2024
+ break;
2025
+ }
2026
+ lastStatus = candidate.status;
2027
+ }
2028
+ catch {
2029
+ /* unreachable lock — try next */
2030
+ }
2031
+ finally {
2032
+ clearTimeout(timer);
2033
+ }
2000
2034
  }
2001
- if (!res.ok) {
2002
- process.stderr.write(`Bridge returned ${res.status} for /runs/judge-summary\n`);
2035
+ if (!res) {
2036
+ process.stderr.write(`No live bridge served /runs/judge-summary (last status: ${lastStatus || "unreachable"}).\n`);
2003
2037
  process.exit(1);
2004
2038
  }
2005
2039
  const summary = (await res.json());