@dzhechkov/harness-core 0.8.35 → 0.8.36
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/.dz-manifest.json +42 -42
- package/README.md +101 -3
- package/dist/apply-leg.d.ts +38 -0
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +263 -16
- package/dist/apply-leg.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +67 -5
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks.d.ts +13 -1
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +13 -1
- package/dist/codex-hooks.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/operations.d.ts +1 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +18 -2
- package/dist/operations.js.map +1 -1
- package/dist/publish.d.ts +59 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +205 -32
- package/dist/publish.js.map +1 -1
- package/dist/release-line.d.ts +16 -0
- package/dist/release-line.d.ts.map +1 -1
- package/dist/release-line.js +31 -0
- package/dist/release-line.js.map +1 -1
- package/dist/vector-tier.d.ts +34 -3
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +105 -14
- package/dist/vector-tier.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +41 -41
- package/src/apply-leg.ts +282 -14
- package/src/codex-hooks-assets.ts +67 -5
- package/src/codex-hooks.ts +13 -1
- package/src/index.ts +6 -1
- package/src/operations.ts +18 -3
- package/src/publish.ts +247 -30
- package/src/release-line.ts +32 -0
- package/src/vector-tier.ts +127 -14
package/src/apply-leg.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { connect as netConnect } from 'node:net';
|
|
|
33
33
|
// (core-boundary.test.ts's ratchet only counts fs/child_process/https). `probeApplyLeg`'s temp probe
|
|
34
34
|
// cwd reuses the existing top-level 'node:fs' import above (mkdtempSync/rmSync added to that SAME
|
|
35
35
|
// import statement, not a new one) so the ratchet stays at its pinned files:63 imports:69.
|
|
36
|
-
import { tmpdir } from 'node:os';
|
|
36
|
+
import { tmpdir, uptime } from 'node:os';
|
|
37
37
|
import { join, resolve } from 'node:path';
|
|
38
38
|
import { hookCommandsOf } from './managed-hooks.js';
|
|
39
39
|
import { patternRecordId, recordPattern, removePatternsByIds, loadStorePatternsSync, type PatternRecord, type RemovePatternsResult } from './patterns.js';
|
|
@@ -1768,6 +1768,19 @@ export interface ApplyLegProbeResult {
|
|
|
1768
1768
|
* reason=…` stderr line when present, else a best-effort description of what went wrong. */
|
|
1769
1769
|
readonly reason?: string;
|
|
1770
1770
|
readonly elapsedMs: number;
|
|
1771
|
+
/** Fix round 1 (HIGH-1): the KILL-ATTEMPT fact, straight from `probeHookLiveness`'s own
|
|
1772
|
+
* `groupKillAttempted` — present only on the branches that actually reached a spawn (absent for the
|
|
1773
|
+
* early "not installed" / "no hook configured" / "legacy command" / "beacon write failed"
|
|
1774
|
+
* returns, none of which ever call `probeHookLiveness`). This is deliberately a DIFFERENT fact
|
|
1775
|
+
* from "is the grandchild still alive" — a caller observes both separately, never conflates them. */
|
|
1776
|
+
readonly groupKillAttempted?: boolean;
|
|
1777
|
+
/** FR-3 (feature `apply-leg-daemon-hygiene`): present exactly when the pre-probe scavenge of
|
|
1778
|
+
* STALE beacon-domain records failed — the reason comes straight from {@link
|
|
1779
|
+
* scavengeStaleProbeBeacons}'s own `error`, never swallowed. Independent of `ok`/`reason`: a
|
|
1780
|
+
* scavenge failure does not itself flip `ok` to `false` (the probe's own injection result may
|
|
1781
|
+
* still be perfectly genuine), but it IS a fact `dz doctor`/`dz parity` callers can surface
|
|
1782
|
+
* rather than lose. */
|
|
1783
|
+
readonly scavengeError?: string;
|
|
1771
1784
|
}
|
|
1772
1785
|
|
|
1773
1786
|
/** Words a real prompt needs to clear `hasEnoughSignal` (recall-hook-policy.ts: `MIN_PROMPT_CHARS`
|
|
@@ -1778,6 +1791,221 @@ const PROBE_PROMPT_WORDS = 'apply leg live probe';
|
|
|
1778
1791
|
* findable and excludable — never `dz-teach`/`general`, which would blend it into real lessons. */
|
|
1779
1792
|
const PROBE_BEACON_DOMAIN = 'apply-leg-probe';
|
|
1780
1793
|
|
|
1794
|
+
/**
|
|
1795
|
+
* FR-3 (feature `apply-leg-daemon-hygiene`): the scavenger that ran at the top of every probe used
|
|
1796
|
+
* to delete EVERY beacon-domain record unconditionally — safe against a probe killed mid-flight
|
|
1797
|
+
* (Codex round-2's own reason for the scavenger existing at all), but WRONG the moment two probes
|
|
1798
|
+
* from two DIFFERENT sessions can be live against the SAME store at once: the second probe's
|
|
1799
|
+
* scavenge deletes the first probe's still-in-flight beacon, and the first probe then reports a
|
|
1800
|
+
* false `ok:false` (its own hook query returns nothing, because the lesson it was about to match
|
|
1801
|
+
* against is already gone) — a false-red `dz doctor`/`dz parity` parity check with no defect behind
|
|
1802
|
+
* it. The fix: tag every beacon with its OWNER (`probe-owner=<pid>:<startedMs>`, embedded in the
|
|
1803
|
+
* pattern TEXT so it survives a round-trip through any store tier without a schema change — NFR-1
|
|
1804
|
+
* forbids a new column) and scavenge ONLY a beacon whose owner is provably gone: the pid no longer
|
|
1805
|
+
* answers `process.kill(pid, 0)`, OR the beacon has outlived its TTL (see {@link
|
|
1806
|
+
* scavengeStaleProbeBeacons}'s own `ttlMs` parameter).
|
|
1807
|
+
*/
|
|
1808
|
+
const PROBE_OWNER_TAG_PREFIX = 'probe-owner=';
|
|
1809
|
+
/** Fix round 1 (HIGH-6 residual): a second, independent tag alongside `probe-owner=` — the WRITING
|
|
1810
|
+
* process's own OS-level start time (epoch ms, from `/proc/<pid>/stat`), so the scavenger can tell a
|
|
1811
|
+
* PID-REUSE case (the recorded pid is technically "alive" per `process.kill(pid,0)`, but the LIVE
|
|
1812
|
+
* process at that pid started at a different time than the one that wrote the beacon) apart from
|
|
1813
|
+
* the genuine same-process case. Absent whenever the write-time `/proc` read fails (non-Linux,
|
|
1814
|
+
* permission) — the scavenger then falls back to the plain alive+TTL check alone, unchanged. */
|
|
1815
|
+
const PROC_START_TAG_PREFIX = 'proc-start=';
|
|
1816
|
+
/** Lead delta after Codex round 2 (HIGH-4): the beacon's OWN expiry, in epoch ms, written by the
|
|
1817
|
+
* probe that owns it. The pre-delta scavenger applied ITS OWN `ttlMs` to SOMEONE ELSE'S beacon, so a
|
|
1818
|
+
* default-budget probe (ttl 60 s) deleted the live beacon of a widened-budget probe (ttl 360 s) after
|
|
1819
|
+
* 60 s — the very false-red this hardening exists to prevent, one level up. A beacon now states when
|
|
1820
|
+
* IT expires; the scavenger's own `ttlMs` is only the fallback for a beacon written before this tag
|
|
1821
|
+
* existed. */
|
|
1822
|
+
const PROBE_EXPIRES_TAG_PREFIX = 'probe-expires=';
|
|
1823
|
+
|
|
1824
|
+
/**
|
|
1825
|
+
* Fix round 1 (HIGH-6): the pre-fix-round TTL was a flat 60 000 ms, independent of the probe's own
|
|
1826
|
+
* `timeoutMs` — a caller that legitimately widens `timeoutMs` past that (a widened, suspended, or
|
|
1827
|
+
* heavily loaded probe) could have its OWN still-in-flight beacon scavenged by a concurrent probe
|
|
1828
|
+
* before it ever replies. The TTL is now DERIVED from the probe's own `timeoutMs`
|
|
1829
|
+
* (`max(timeoutMs * 3, 60_000)`, see {@link probeApplyLeg}'s call site) so a widened timeout widens
|
|
1830
|
+
* its own protection window too; the 60 000 ms floor keeps the pre-fix-round generous margin for the
|
|
1831
|
+
* default (unwidened) case. Exported as a named constant only for the floor value — the ACTUAL TTL
|
|
1832
|
+
* used by a given probe is always `ttlMs`, computed at the call site, never this constant alone.
|
|
1833
|
+
*/
|
|
1834
|
+
const PROBE_BEACON_TTL_FLOOR_MS = 60_000;
|
|
1835
|
+
|
|
1836
|
+
function formatProbeOwner(pid: number, startedMs: number): string {
|
|
1837
|
+
return `${PROBE_OWNER_TAG_PREFIX}${pid}:${startedMs}`;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
/** Parses the `probe-owner=<pid>:<startedMs>` tag out of a beacon's pattern text. `undefined` for
|
|
1841
|
+
* any beacon predating this tag (an older deployed core wrote it) — treated by the scavenger as
|
|
1842
|
+
* ownerless and therefore always safe to remove (the pre-FR-3 behavior for exactly that case). */
|
|
1843
|
+
function parseProbeOwner(patternText: string): { readonly pid: number; readonly startedMs: number } | undefined {
|
|
1844
|
+
const idx = patternText.indexOf(PROBE_OWNER_TAG_PREFIX);
|
|
1845
|
+
if (idx === -1) return undefined;
|
|
1846
|
+
const match = /probe-owner=(\d+):(\d+)/u.exec(patternText.slice(idx));
|
|
1847
|
+
if (match?.[1] === undefined || match[2] === undefined) return undefined;
|
|
1848
|
+
return { pid: Number(match[1]), startedMs: Number(match[2]) };
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
function formatProcStart(procStartedAtMs: number): string {
|
|
1852
|
+
return ` ${PROC_START_TAG_PREFIX}${procStartedAtMs}`;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
function formatProbeExpires(expiresAtMs: number): string {
|
|
1856
|
+
return ` ${PROBE_EXPIRES_TAG_PREFIX}${expiresAtMs}`;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/** Parses the `probe-expires=<epochMs>` tag — `undefined` for a beacon written before the tag
|
|
1860
|
+
* existed, which is exactly when the scavenger falls back to its own `ttlMs`. */
|
|
1861
|
+
function parseProbeExpires(patternText: string): number | undefined {
|
|
1862
|
+
const idx = patternText.indexOf(PROBE_EXPIRES_TAG_PREFIX);
|
|
1863
|
+
if (idx === -1) return undefined;
|
|
1864
|
+
const match = /probe-expires=(\d+)/u.exec(patternText.slice(idx));
|
|
1865
|
+
if (match?.[1] === undefined) return undefined;
|
|
1866
|
+
return Number(match[1]);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
/** Parses the `proc-start=<ticks>` tag — `undefined` when absent (pre-fix-round beacon, or the
|
|
1870
|
+
* write-time `/proc` read failed). */
|
|
1871
|
+
function parseProcStart(patternText: string): number | undefined {
|
|
1872
|
+
const idx = patternText.indexOf(PROC_START_TAG_PREFIX);
|
|
1873
|
+
if (idx === -1) return undefined;
|
|
1874
|
+
const match = /proc-start=(\d+)/u.exec(patternText.slice(idx));
|
|
1875
|
+
if (match?.[1] === undefined) return undefined;
|
|
1876
|
+
return Number(match[1]);
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
/** True when `pid` answers a liveness signal — `process.kill(pid, 0)` sends no actual signal, it
|
|
1880
|
+
* only probes whether the OS still has a process at that pid (ESRCH ⇒ dead). */
|
|
1881
|
+
function isPidAlive(pid: number): boolean {
|
|
1882
|
+
try {
|
|
1883
|
+
process.kill(pid, 0);
|
|
1884
|
+
return true;
|
|
1885
|
+
} catch (err) {
|
|
1886
|
+
return (err as NodeJS.ErrnoException).code !== 'ESRCH';
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
/**
|
|
1891
|
+
* Fix round 1 (HIGH-6 residual, Linux-only, best-effort), tightened by the lead after Codex round 2
|
|
1892
|
+
* (MEDIUM-6): a process's OWN `starttime` from `/proc/<pid>/stat` — field 22 overall, found by
|
|
1893
|
+
* skipping past the LAST `)` so a `comm` containing spaces or parens never misaligns the split —
|
|
1894
|
+
* returned as RAW TICKS SINCE BOOT, the unit the kernel reports. No wall clock is consulted and no
|
|
1895
|
+
* CLK_TCK conversion is performed, so a stepped wall clock can no longer make the same live process
|
|
1896
|
+
* look like a different one. `undefined` on ANY read/parse failure (non-Linux, permission, the
|
|
1897
|
+
* process exiting mid-read) — the caller MUST treat that as "cannot prove", never as a pass in
|
|
1898
|
+
* either direction.
|
|
1899
|
+
*/
|
|
1900
|
+
function pidStartedAtMsFromProcStat(pid: number): number | undefined {
|
|
1901
|
+
try {
|
|
1902
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf-8');
|
|
1903
|
+
const afterComm = stat.slice(stat.lastIndexOf(')') + 1).trim();
|
|
1904
|
+
const fields = afterComm.split(/\s+/u);
|
|
1905
|
+
// Overall field 22 (`starttime`) = fields[22 - 3] here, since fields[] starts at overall field 3
|
|
1906
|
+
// (state) once `pid (comm)` (fields 1-2) has been stripped above.
|
|
1907
|
+
const starttimeTicks = Number(fields[19]);
|
|
1908
|
+
if (!Number.isFinite(starttimeTicks)) return undefined;
|
|
1909
|
+
// Lead delta after Codex round 2 (MEDIUM-6): return the RAW ticks-since-boot, never an epoch
|
|
1910
|
+
// derived from `Date.now() - uptime()`. The derived epoch moves when the WALL CLOCK is stepped,
|
|
1911
|
+
// so the same still-running process appeared to have "started at a different time" and its LIVE
|
|
1912
|
+
// beacon was deleted as a pid-reuse. Ticks since boot are monotonic within a boot and identical
|
|
1913
|
+
// for the same process on every read; across a reboot the recorded pid is dead anyway, which the
|
|
1914
|
+
// liveness check catches first — WITHDRAWN by the lead after Codex round 4: that sentence was
|
|
1915
|
+
// wrong, because across a reboot the pid may ALREADY have been reused and can collide on start
|
|
1916
|
+
// ticks too; the honest scope is the NAMED LIMIT stated below. This also removes the CLK_TCK assumption entirely — no conversion
|
|
1917
|
+
// to milliseconds happens at all, the two values are compared in their own unit.
|
|
1918
|
+
// NAMED LIMIT (lead delta after Codex round 3, MEDIUM): identity holds WITHIN one boot. Across a
|
|
1919
|
+
// reboot both pid allocation and ticks-since-boot restart, so a beacon that somehow persisted
|
|
1920
|
+
// could in principle collide with a new process at the same pid and the same tick. A boot id
|
|
1921
|
+
// would close it; the honest scope today is "within one boot", and a reboot also means the
|
|
1922
|
+
// beacon's own TTL has almost certainly passed, which the TTL branch catches first.
|
|
1923
|
+
return starttimeTicks;
|
|
1924
|
+
} catch {
|
|
1925
|
+
return undefined;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
/** Fix round 1 (HIGH-6 residual): `process.kill(pid,0)` proves SOME process occupies `pid`, never
|
|
1930
|
+
* that it is the SAME process that wrote the beacon — PID reuse defeats the plain alive check named
|
|
1931
|
+
* as a residual limit in the review. Compares the LIVE process's own start time (from `/proc/<pid>/
|
|
1932
|
+
* stat`) against `recordedStartedMs` (the beacon's own `proc-start=` tag). A mismatch beyond {@link
|
|
1933
|
+
* PID_START_TOLERANCE_MS} means the pid was reused by an unrelated process — the true owner is
|
|
1934
|
+
* confirmed gone. `undefined` (stat unreadable) means "cannot prove either way" — the lead's decision
|
|
1935
|
+
* (fix round 1, item 6) is explicit: that MUST read as "do not remove" wherever it is consumed, never
|
|
1936
|
+
* as a pass. */
|
|
1937
|
+
function isSameProcessInstance(pid: number, recordedStartTicks: number): boolean | undefined {
|
|
1938
|
+
const actualStartTicks = pidStartedAtMsFromProcStat(pid);
|
|
1939
|
+
if (actualStartTicks === undefined) return undefined;
|
|
1940
|
+
// Lead delta after Codex round 2 (MEDIUM-6): EXACT equality on ticks-since-boot. The old
|
|
1941
|
+
// ±5 000 ms tolerance existed only to absorb the CLK_TCK guess in the epoch conversion; with the
|
|
1942
|
+
// raw kernel value there is nothing to absorb, and a tolerance would re-admit the very pid-reuse
|
|
1943
|
+
// case it was meant to exclude (a reused pid started within the tolerance read as "same process").
|
|
1944
|
+
return actualStartTicks === recordedStartTicks;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/** {@link scavengeStaleProbeBeacons}'s result — FR-3: a scavenge error is a FACT the caller can
|
|
1948
|
+
* surface, never a swallowed exception (the pre-fix `catch { /* best-effort *\/ }` this replaces). */
|
|
1949
|
+
export type ScavengeResult = { readonly ok: true; readonly removed: number } | { readonly ok: false; readonly error: string };
|
|
1950
|
+
|
|
1951
|
+
/**
|
|
1952
|
+
* FR-3/FR-4: remove only the STALE beacon-domain records in `root`'s store — owner dead
|
|
1953
|
+
* (`process.kill(pid, 0)` ⇒ ESRCH), older than `ttlMs`, OR (fix round 1, HIGH-6 residual) alive AND
|
|
1954
|
+
* within `ttlMs` but the live pid's OWN `/proc/<pid>/stat` start time no longer matches the beacon's
|
|
1955
|
+
* `proc-start=` tag — a confirmed PID-reuse case, where the recorded owner is provably gone even
|
|
1956
|
+
* though `pid` itself answers. An UNREADABLE stat at scavenge time never counts as reuse evidence —
|
|
1957
|
+
* it falls back to the plain alive+TTL verdict, per the lead's "cannot prove ⇒ do not remove"
|
|
1958
|
+
* decision. A beacon whose owner is alive, within TTL, and (when provable) confirmed the SAME
|
|
1959
|
+
* process is left untouched, even though it belongs to a different probe. An ownerless (pre-FR-3)
|
|
1960
|
+
* beacon is always treated as stale. `ttlMs` defaults to {@link PROBE_BEACON_TTL_FLOOR_MS} for a
|
|
1961
|
+
* caller that does not derive one (`apply-leg-beacon-owner.test.ts`'s own fixtures); `probeApplyLeg`
|
|
1962
|
+
* always passes its own derived value. Exported so `apply-leg-beacon-owner.test.ts` can exercise the
|
|
1963
|
+
* property directly, without needing a live hook/daemon (FR-4's red-first case needs only a store
|
|
1964
|
+
* and an injectable remover, never a real probe round-trip).
|
|
1965
|
+
*/
|
|
1966
|
+
export function scavengeStaleProbeBeacons(
|
|
1967
|
+
root: string,
|
|
1968
|
+
removeBeacon: (root: string, ids: ReadonlySet<string>) => RemovePatternsResult,
|
|
1969
|
+
ttlMs: number = PROBE_BEACON_TTL_FLOOR_MS,
|
|
1970
|
+
): ScavengeResult {
|
|
1971
|
+
try {
|
|
1972
|
+
const beacons = loadStorePatternsSync(root).filter((p) => p.domain === PROBE_BEACON_DOMAIN);
|
|
1973
|
+
const staleIds: string[] = [];
|
|
1974
|
+
for (const beacon of beacons) {
|
|
1975
|
+
const owner = parseProbeOwner(beacon.pattern);
|
|
1976
|
+
if (owner === undefined) {
|
|
1977
|
+
staleIds.push(beacon.dzId ?? patternRecordId(beacon));
|
|
1978
|
+
continue;
|
|
1979
|
+
}
|
|
1980
|
+
const alive = isPidAlive(owner.pid);
|
|
1981
|
+
// Lead delta after Codex round 2 (HIGH-4): the beacon's OWN expiry wins over this scavenger's
|
|
1982
|
+
// `ttlMs`, which belongs to a DIFFERENT probe and knows nothing of this owner's budget.
|
|
1983
|
+
const ownExpiresAtMs = parseProbeExpires(beacon.pattern);
|
|
1984
|
+
const withinTtl = ownExpiresAtMs !== undefined
|
|
1985
|
+
? Date.now() <= ownExpiresAtMs
|
|
1986
|
+
: Date.now() - owner.startedMs <= ttlMs;
|
|
1987
|
+
if (!alive || !withinTtl) {
|
|
1988
|
+
staleIds.push(beacon.dzId ?? patternRecordId(beacon));
|
|
1989
|
+
continue;
|
|
1990
|
+
}
|
|
1991
|
+
// alive AND within TTL — the plain check says "keep", but confirm it is the SAME process, not
|
|
1992
|
+
// a reused pid, whenever the beacon carries the (best-effort) proc-start tag.
|
|
1993
|
+
const procStart = parseProcStart(beacon.pattern);
|
|
1994
|
+
if (procStart !== undefined) {
|
|
1995
|
+
const same = isSameProcessInstance(owner.pid, procStart);
|
|
1996
|
+
if (same === false) staleIds.push(beacon.dzId ?? patternRecordId(beacon)); // confirmed reuse
|
|
1997
|
+
// same === true, or same === undefined (unreadable ⇒ cannot prove ⇒ do not remove): keep.
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
if (staleIds.length === 0) return { ok: true, removed: 0 };
|
|
2001
|
+
const result = removeBeacon(root, new Set(staleIds));
|
|
2002
|
+
if (result.error !== undefined) return { ok: false, error: result.error };
|
|
2003
|
+
return { ok: true, removed: result.removed };
|
|
2004
|
+
} catch (err) {
|
|
2005
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
|
|
1781
2009
|
/**
|
|
1782
2010
|
* Live, end-to-end proof that the apply leg actually injects — ADR-001 Decision 1. `applyLegStatus`
|
|
1783
2011
|
* only proves FILES exist and are STRUCTURALLY wired (issue #2's whole defect: four green checks,
|
|
@@ -1821,6 +2049,12 @@ export async function probeApplyLeg(
|
|
|
1821
2049
|
const started = Date.now();
|
|
1822
2050
|
const elapsed = (): number => Date.now() - started;
|
|
1823
2051
|
|
|
2052
|
+
// Fix round 1 (HIGH-6): computed HERE, before the scavenger runs, so the scavenge TTL can be
|
|
2053
|
+
// derived from THIS probe's own budget rather than a flat constant blind to a caller-widened
|
|
2054
|
+
// timeout — see the `ttlMs` computation below, right before the beacon that carries its implicit
|
|
2055
|
+
// promise is written.
|
|
2056
|
+
const timeoutMs = opts.timeoutMs ?? 8000;
|
|
2057
|
+
|
|
1824
2058
|
const status = applyLegStatus(root);
|
|
1825
2059
|
if (!status.installed) {
|
|
1826
2060
|
return { ok: false, reason: 'apply-leg not installed', elapsedMs: elapsed() };
|
|
@@ -1844,8 +2078,32 @@ export async function probeApplyLeg(
|
|
|
1844
2078
|
// pattern's own text can produce it. `ok: true` therefore requires the SECRET, never the query.
|
|
1845
2079
|
const queryToken = `dzapplylegquery${process.pid}${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
|
|
1846
2080
|
const secretToken = `dzapplylegsecret${process.pid}${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
|
|
2081
|
+
// FR-3: the owner tag rides the SAME pattern text as the secret — a beacon's owner is knowable
|
|
2082
|
+
// from its store record alone, no side channel, no schema change (NFR-1).
|
|
2083
|
+
const ownerTag = formatProbeOwner(process.pid, started);
|
|
2084
|
+
// Fix round 1 (HIGH-6): TTL derives from THIS probe's own timeoutMs — a caller that widens
|
|
2085
|
+
// timeoutMs (this repo's own live tests widen it to 15_000 ms) widens its own protection window
|
|
2086
|
+
// too, rather than staying pinned to a flat constant blind to that widening. Floored at the
|
|
2087
|
+
// pre-fix-round 60_000 ms so the default (unwidened) case keeps its original generous margin.
|
|
2088
|
+
// Asserted, not merely trusted — an unreasoned future edit to the formula must fail loudly right
|
|
2089
|
+
// here, where the beacon carrying this TTL's implicit promise is about to be written, rather than
|
|
2090
|
+
// silently reintroducing the false-red-under-concurrency defect this closes.
|
|
2091
|
+
const ttlMs = Math.max(timeoutMs * 3, PROBE_BEACON_TTL_FLOOR_MS);
|
|
2092
|
+
if (!(timeoutMs < ttlMs)) {
|
|
2093
|
+
throw new Error(`invariant violated: timeoutMs (${String(timeoutMs)}) must be < ttlMs (${String(ttlMs)}) — scavenger TTL formula regressed`);
|
|
2094
|
+
}
|
|
2095
|
+
// Fix round 1 (HIGH-6 residual, best-effort): the writing process's OWN OS-level start time,
|
|
2096
|
+
// captured NOW so a later scavenger can tell a genuinely-still-alive owner apart from a DIFFERENT
|
|
2097
|
+
// process that merely reused this pid (isSameProcessInstance's own doc comment). Omitted entirely
|
|
2098
|
+
// when unreadable (non-Linux, permission) — the scavenger then falls back to the plain alive+TTL
|
|
2099
|
+
// check for this beacon, unchanged from before this residual hardening.
|
|
2100
|
+
const ownProcStart = pidStartedAtMsFromProcStat(process.pid);
|
|
2101
|
+
const procStartTag = ownProcStart !== undefined ? formatProcStart(ownProcStart) : '';
|
|
2102
|
+
// Lead delta after Codex round 2 (HIGH-4): this beacon states its OWN expiry, so a concurrent
|
|
2103
|
+
// probe with a different (shorter) budget can never out-vote this probe's protection window.
|
|
2104
|
+
const expiresTag = formatProbeExpires(started + ttlMs);
|
|
1847
2105
|
const beaconPattern: PatternRecord = {
|
|
1848
|
-
pattern: `${PROBE_PROMPT_WORDS} ${queryToken} — dz doctor / dz parity live-probe marker, safe to remove. probe-secret=${secretToken}`,
|
|
2106
|
+
pattern: `${PROBE_PROMPT_WORDS} ${queryToken} — dz doctor / dz parity live-probe marker, safe to remove. probe-secret=${secretToken} ${ownerTag}${procStartTag}${expiresTag}`,
|
|
1849
2107
|
type: 'lesson-learned',
|
|
1850
2108
|
reward: 0,
|
|
1851
2109
|
domain: PROBE_BEACON_DOMAIN,
|
|
@@ -1874,12 +2132,13 @@ export async function probeApplyLeg(
|
|
|
1874
2132
|
// Codex round-2: a process killed mid-probe bypasses `finally`, so a beacon can outlive its probe.
|
|
1875
2133
|
// Every probe therefore starts by SCAVENGING any beacon left behind by an earlier one (the probe
|
|
1876
2134
|
// domain is reserved for beacons, never for user lessons) — the store is clean before AND after.
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
2135
|
+
// FR-3/FR-4: scavenging is no longer unconditional — a LIVE beacon from a DIFFERENT concurrent
|
|
2136
|
+
// probe (another session's `dz doctor`/`dz parity` against the same store) must survive; only a
|
|
2137
|
+
// beacon whose owner is dead or past its TTL is removed. A scavenge failure is a named FACT
|
|
2138
|
+
// (`scavengeError`), never a swallowed exception (the pre-fix `catch {}` this replaces).
|
|
2139
|
+
let scavengeError: string | undefined;
|
|
2140
|
+
const scavengeResult = scavengeStaleProbeBeacons(root, removeBeacon, ttlMs);
|
|
2141
|
+
if (!scavengeResult.ok) scavengeError = scavengeResult.error;
|
|
1883
2142
|
|
|
1884
2143
|
try {
|
|
1885
2144
|
wrote = true;
|
|
@@ -1903,7 +2162,6 @@ export async function probeApplyLeg(
|
|
|
1903
2162
|
mkdirSync(join(tempCwd, '.claude'), { recursive: true });
|
|
1904
2163
|
|
|
1905
2164
|
const { probeHookLiveness } = await import('./operations.js');
|
|
1906
|
-
const timeoutMs = opts.timeoutMs ?? 8000;
|
|
1907
2165
|
const probeResult = probeHookLiveness(command, JSON.stringify({ prompt: probePrompt }), {
|
|
1908
2166
|
cwd: tempCwd,
|
|
1909
2167
|
env: { ...(opts.env ?? {}), CLAUDE_PROJECT_DIR: tempCwd },
|
|
@@ -1924,12 +2182,12 @@ export async function probeApplyLeg(
|
|
|
1924
2182
|
}
|
|
1925
2183
|
|
|
1926
2184
|
if (typeof additionalContext === 'string' && additionalContext.includes(secretToken)) {
|
|
1927
|
-
result = { ok: true, elapsedMs: elapsed() };
|
|
2185
|
+
result = { ok: true, elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
|
|
1928
2186
|
} else if (typeof additionalContext === 'string' && additionalContext.includes(queryToken)) {
|
|
1929
2187
|
// AM-1: the QUERY came back but the SECRET did not — the hook (or a stub standing in for
|
|
1930
2188
|
// it) echoed its own input instead of genuinely querying the store. Named distinctly from
|
|
1931
2189
|
// every other red reason so a dead leg and a FAKING one never read the same.
|
|
1932
|
-
result = { ok: false, reason: 'echo-not-injection', elapsedMs: elapsed() };
|
|
2190
|
+
result = { ok: false, reason: 'echo-not-injection', elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
|
|
1933
2191
|
} else {
|
|
1934
2192
|
// FR-1's own reason line is the authoritative source — the hook names itself why it stayed
|
|
1935
2193
|
// quiet. Falling back to a raw stderr/status summary keeps the probe honest even against an
|
|
@@ -1937,15 +2195,16 @@ export async function probeApplyLeg(
|
|
|
1937
2195
|
const skipMatch = /\[dz-recall\] skipped reason=(\S+)/.exec(probeResult.stderr);
|
|
1938
2196
|
const skipReason = skipMatch?.[1];
|
|
1939
2197
|
if (skipReason !== undefined) {
|
|
1940
|
-
result = { ok: false, reason: skipReason, elapsedMs: elapsed() };
|
|
2198
|
+
result = { ok: false, reason: skipReason, elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
|
|
1941
2199
|
} else if (probeResult.status === null) {
|
|
1942
|
-
result = { ok: false, reason: `probe did not complete (timeout or spawn error after ${timeoutMs} ms)`, elapsedMs: elapsed() };
|
|
2200
|
+
result = { ok: false, reason: `probe did not complete (timeout or spawn error after ${timeoutMs} ms)`, elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
|
|
1943
2201
|
} else {
|
|
1944
2202
|
const stderrFirstLine = probeResult.stderr.trim().split('\n')[0];
|
|
1945
2203
|
result = {
|
|
1946
2204
|
ok: false,
|
|
1947
2205
|
reason: stderrFirstLine && stderrFirstLine !== '' ? stderrFirstLine : 'no beacon in additionalContext (empty or non-matching reply)',
|
|
1948
2206
|
elapsedMs: elapsed(),
|
|
2207
|
+
groupKillAttempted: probeResult.groupKillAttempted,
|
|
1949
2208
|
};
|
|
1950
2209
|
}
|
|
1951
2210
|
}
|
|
@@ -1978,11 +2237,20 @@ export async function probeApplyLeg(
|
|
|
1978
2237
|
}
|
|
1979
2238
|
|
|
1980
2239
|
if (cleanupFailed) {
|
|
2240
|
+
// a cleanup failure can only happen after `result` was assigned (the finally block runs after
|
|
2241
|
+
// the try body) — carry the kill-attempt fact through rather than dropping it on this path.
|
|
2242
|
+
// `exactOptionalPropertyTypes` forbids assigning `undefined` to an optional field explicitly, so
|
|
2243
|
+
// the key is included only when `result.groupKillAttempted` actually has a value.
|
|
1981
2244
|
return {
|
|
1982
2245
|
ok: false,
|
|
1983
2246
|
reason: `beacon-cleanup-failed: beacon ${beaconId} could not be removed (${cleanupErrMsg})`,
|
|
1984
2247
|
elapsedMs: elapsed(),
|
|
2248
|
+
...(result.groupKillAttempted !== undefined ? { groupKillAttempted: result.groupKillAttempted } : {}),
|
|
2249
|
+
...(scavengeError !== undefined ? { scavengeError } : {}),
|
|
1985
2250
|
};
|
|
1986
2251
|
}
|
|
1987
|
-
|
|
2252
|
+
// FR-3: a scavenge failure is surfaced on the SUCCESS path too — it does not override `ok`/
|
|
2253
|
+
// `reason` (this probe's own injection result may be perfectly genuine), but it is a fact a
|
|
2254
|
+
// caller should not lose.
|
|
2255
|
+
return scavengeError !== undefined ? { ...result, scavengeError } : result;
|
|
1988
2256
|
}
|
|
@@ -141,6 +141,66 @@ function findProjectRoot(startDir) {
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* FR-1 (codex-hook-root-provenance): the ONE place both hooks compute their start directory and
|
|
146
|
+
* walk to a project root — replacing two independent copies of the same ternary. T1 (fix-round 1:
|
|
147
|
+
* the original reproducer had an unexported \`BASE\`, so it measured the wrong file; corrected and
|
|
148
|
+
* re-run — see the feature's change manifest for both) measured LIVE on \`codex-cli 0.154.0\`: three
|
|
149
|
+
* real \`codex exec\` sessions (project root, a nested subdirectory, a directory with no \`.dz\`
|
|
150
|
+
* anywhere in its ancestry), each with BOTH hook events (\`PreToolUse\` and \`UserPromptSubmit\`)
|
|
151
|
+
* captured SEPARATELY. \`payload.cwd\` was present and equal to both \`PWD\` and the hook's own
|
|
152
|
+
* \`process.cwd()\` in every one of the 6 captures. \`PWD\`/\`process.cwd()\` therefore stay only as a
|
|
153
|
+
* DEFENSIVE fallback for a payload shaped without \`cwd\` — not because that fallback was ever
|
|
154
|
+
* observed to fire. This is a SCOPED finding, not a claim that the "hook read the wrong project"
|
|
155
|
+
* defect class cannot exist: it was not observed on codex-cli 0.154.0 across these 3 scenarios / 6
|
|
156
|
+
* captures, and 01_requirements.md's own Ограничение C-3 is what permits cutting FR-3 (the explicit
|
|
157
|
+
* \`DZ_PROJECT_ROOT\` override) on a scoped finding like that — not a claim of nonexistence.
|
|
158
|
+
*/
|
|
159
|
+
function resolveHookRoot(payload) {
|
|
160
|
+
const hasPayloadCwd = typeof payload.cwd === 'string' && payload.cwd !== '';
|
|
161
|
+
const startDir = hasPayloadCwd ? payload.cwd : (process.env.PWD || process.cwd());
|
|
162
|
+
const source = hasPayloadCwd ? 'payload-cwd' : (process.env.PWD ? 'env-pwd' : 'process-cwd');
|
|
163
|
+
return { root: findProjectRoot(startDir), source, startDir };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Fix-round 1, item 4: a path interpolated into the provenance line below can itself carry control
|
|
168
|
+
* characters — a \`payload.cwd\` from an untrusted producer, or a \`PWD\` set to something hostile —
|
|
169
|
+
* and a bare newline in the middle of it would defeat the "ONE line" promise the diagnostic makes.
|
|
170
|
+
* Escape the whole C0 range (0x00-0x1F) plus DEL (0x7F) into a visible \`\\n\`/\\r\`/\\t\`/\\xHH\`
|
|
171
|
+
* representation; every other byte, including non-ASCII path segments, passes through unchanged.
|
|
172
|
+
*/
|
|
173
|
+
function escapeControlChars(value) {
|
|
174
|
+
return String(value).replace(/[\\x00-\\x1f\\x7f]/g, function (ch) {
|
|
175
|
+
var code = ch.charCodeAt(0);
|
|
176
|
+
if (code === 10) return '\\\\n';
|
|
177
|
+
if (code === 13) return '\\\\r';
|
|
178
|
+
if (code === 9) return '\\\\t';
|
|
179
|
+
return '\\\\x' + code.toString(16).padStart(2, '0');
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* FR-2: ONE provenance line, same shape as the Claude hook's (\`apply-leg.ts\`'s \`skip()\`), printed
|
|
185
|
+
* to stderr. When no root was found it ALWAYS prints (the walk's whole verdict was silent before
|
|
186
|
+
* this feature); when a root WAS found it prints only under \`DZ_CODEX_HOOK_DEBUG\`, so the found
|
|
187
|
+
* path stays byte-for-byte silent by default (NFR-2). Tradeoff, named plainly: this line discloses
|
|
188
|
+
* the absolute directory the hook was asked about (which can embed a username, a customer or
|
|
189
|
+
* repository name) to stderr — accepted because it is a diagnostic aimed at the person running the
|
|
190
|
+
* hook, not a return value, and redacting it would make the not-found case as silent as the bug
|
|
191
|
+
* this feature exists to fix. \`startDir\`/\`root\` are escaped via \`escapeControlChars\` first, so an
|
|
192
|
+
* adversarial value cannot itself defeat the "ONE line" guarantee.
|
|
193
|
+
*/
|
|
194
|
+
function reportRootProvenance(resolved) {
|
|
195
|
+
if (resolved.root === null) {
|
|
196
|
+
process.stderr.write(\`[\${HELPER}] skipped reason=no-project-root start=\${escapeControlChars(resolved.startDir)} (\${resolved.source})\\n\`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (process.env.DZ_CODEX_HOOK_DEBUG) {
|
|
200
|
+
process.stderr.write(\`[\${HELPER}] root=\${escapeControlChars(resolved.root)} start=\${escapeControlChars(resolved.startDir)} (\${resolved.source})\\n\`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
144
204
|
function readProjectConfig(root) {
|
|
145
205
|
try {
|
|
146
206
|
return JSON.parse(fs.readFileSync(path.join(root, '.dz', 'config.json'), 'utf8'));
|
|
@@ -229,9 +289,10 @@ async function main() {
|
|
|
229
289
|
const command = input && typeof input === 'object' ? input.command : undefined;
|
|
230
290
|
if (typeof command !== 'string' || command === '') return 0;
|
|
231
291
|
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
292
|
+
const resolved = resolveHookRoot(payload);
|
|
293
|
+
reportRootProvenance(resolved);
|
|
294
|
+
const root = resolved.root;
|
|
295
|
+
if (root === null) return 0; // inert outside an opted-in dz project: no DECISION and no WRITE — one diagnostic line on stderr (FR-2), nothing else
|
|
235
296
|
|
|
236
297
|
// (1) The destructive-command guard. Never blocks on our own failure: an absent module, a throw,
|
|
237
298
|
// or an \`undecidable\` verdict all fall through to the shell veto below (AC-10).
|
|
@@ -370,8 +431,9 @@ async function main() {
|
|
|
370
431
|
const prompt = typeof payload.prompt === 'string' ? payload.prompt : '';
|
|
371
432
|
if (prompt.trim() === '') return;
|
|
372
433
|
|
|
373
|
-
const
|
|
374
|
-
|
|
434
|
+
const resolved = resolveHookRoot(payload);
|
|
435
|
+
reportRootProvenance(resolved);
|
|
436
|
+
const root = resolved.root;
|
|
375
437
|
if (root === null) return; // inert outside an opted-in dz project
|
|
376
438
|
|
|
377
439
|
const policy = await loadCore(root, 'recall-hook-policy.js', (m) => typeof m.selectHookHits === 'function');
|
package/src/codex-hooks.ts
CHANGED
|
@@ -56,8 +56,20 @@ import { mergeManagedHookEntries } from './managed-hooks.js';
|
|
|
56
56
|
* run was indistinguishable from a clean allow. The Claude hook already failed open loudly here.
|
|
57
57
|
* Now it prints ONE line, `DZ-DESTRUCTIVE-WARN: classifier threw — <message>`, and still exits 0.
|
|
58
58
|
* A changed body ⇒ re-trust.
|
|
59
|
+
* 8 — `codex-hook-root-provenance`: both hooks now share ONE `resolveHookRoot(payload)` instead of
|
|
60
|
+
* two copies of the same `payload.cwd || PWD || cwd()` ternary, and a silent `root === null` early
|
|
61
|
+
* return now prints one provenance line (`[dz-codex-<hook>] skipped reason=no-project-root
|
|
62
|
+
* start=<startDir> (<source>)`); the found-root path stays silent unless `DZ_CODEX_HOOK_DEBUG` is
|
|
63
|
+
* set. T1 (live probe, codex-cli 0.154.0) found `payload.cwd` always present and equal to `PWD`/
|
|
64
|
+
* `process.cwd()`, so no explicit-override knob was added. A changed body ⇒ re-trust.
|
|
65
|
+
* 9 — fix-round 1: the provenance line's interpolated paths are now escaped via
|
|
66
|
+
* `escapeControlChars` (C0 range + DEL) before printing, so a hostile `payload.cwd` cannot defeat
|
|
67
|
+
* the "ONE line" promise with an embedded newline; the corrected T1 re-run (both hook events
|
|
68
|
+
* captured separately, per-scenario — the original reproducer's `BASE` was never exported) reached
|
|
69
|
+
* the SAME conclusion, scoped honestly as "not observed on codex-cli 0.154.0 across 3 scenarios / 6
|
|
70
|
+
* captures", not "does not exist". A changed body ⇒ re-trust.
|
|
59
71
|
*/
|
|
60
|
-
export const DZ_HOOK_HELPER_VERSION =
|
|
72
|
+
export const DZ_HOOK_HELPER_VERSION = 9;
|
|
61
73
|
|
|
62
74
|
/** Seconds. Probe-proven (spike S2): `timeout` is honored, the unset default is 600 s. */
|
|
63
75
|
export const DZ_HOOK_TIMEOUT_SECONDS = 5;
|
package/src/index.ts
CHANGED
|
@@ -203,6 +203,9 @@ export {
|
|
|
203
203
|
mirrorPatternsToVector,
|
|
204
204
|
backfillVectorMirror,
|
|
205
205
|
mergeHybridHits,
|
|
206
|
+
compareHybridHits,
|
|
207
|
+
evidenceRank,
|
|
208
|
+
orderHitsForReRank,
|
|
206
209
|
recallHybrid,
|
|
207
210
|
teachGuard,
|
|
208
211
|
vectorTierStatus,
|
|
@@ -224,6 +227,7 @@ export type {
|
|
|
224
227
|
HybridRecall,
|
|
225
228
|
HybridRecallMode,
|
|
226
229
|
HybridHit,
|
|
230
|
+
HybridOrderKey,
|
|
227
231
|
RankedPattern,
|
|
228
232
|
VectorServiceOptions,
|
|
229
233
|
VectorTierStatus,
|
|
@@ -730,7 +734,8 @@ export type {
|
|
|
730
734
|
ChainDefectAges,
|
|
731
735
|
ChainedJournal,
|
|
732
736
|
} from './event-chain.js';
|
|
733
|
-
export { decideProvenance, environmentCanMintProvenance, publishArgv, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, findUnpublishedWorkspaceFloors, rewriteWorkspaceSpecs, orderByDependencies, syncReadmeVersion, isChangelogEntryLine, changelogRegion } from './publish.js';
|
|
737
|
+
export { decideProvenance, environmentCanMintProvenance, publishArgv, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, findUnpublishedWorkspaceFloors, rewriteWorkspaceSpecs, orderByDependencies, syncReadmeVersion, isChangelogEntryLine, changelogRegion, planReadmeVersionSync } from './publish.js';
|
|
738
|
+
export type { ReadmeVersionSyncPlan, ReadmeSyncRewrite } from './publish.js';
|
|
734
739
|
export { RELEASE_LINE_RE, findReleaseLine, rewriteReleaseLine } from './release-line.js';
|
|
735
740
|
export * from './course-staleness.js';
|
|
736
741
|
export { fetchAllDownloads } from './downloads.js';
|
package/src/operations.ts
CHANGED
|
@@ -1893,8 +1893,19 @@ export function probeHookLiveness(
|
|
|
1893
1893
|
command: string,
|
|
1894
1894
|
payload: string,
|
|
1895
1895
|
opts: { readonly cwd?: string; readonly env?: Readonly<Record<string, string>>; readonly timeoutMs?: number } = {},
|
|
1896
|
-
): { readonly status: number | null; readonly stdout: string; readonly stderr: string } {
|
|
1896
|
+
): { readonly status: number | null; readonly stdout: string; readonly stderr: string; readonly groupKillAttempted: boolean } {
|
|
1897
1897
|
const shell = process.env['SHELL'] ?? '/bin/sh';
|
|
1898
|
+
// Fix round 1 (apply-leg-never-silent, HIGH-1): a caller previously had to INFER "was the group
|
|
1899
|
+
// kill sent" by reading this function's source — a regression removing or bypassing the
|
|
1900
|
+
// `process.kill(-pid, ...)` call below would silently invalidate that inference. `groupKillAttempted`
|
|
1901
|
+
// is the OBSERVABLE fact instead: true exactly when this call reached the point of attempting the
|
|
1902
|
+
// kill syscall (`res.pid` was a real positive pid), false when it never got that far (e.g. the
|
|
1903
|
+
// spawn itself never produced a pid). It does NOT claim the signal found a live recipient — ESRCH
|
|
1904
|
+
// ("group already gone", the common successful-exit case) still counts as "sent": the syscall was
|
|
1905
|
+
// issued, its target simply no longer existed. That is a SEPARATE fact from whether the grandchild
|
|
1906
|
+
// is actually dead by the time a caller checks — see `probeApplyLeg`'s AM-5 test for the
|
|
1907
|
+
// kill-sent-vs-death-observed split this field exists to make possible.
|
|
1908
|
+
let groupKillAttempted = false;
|
|
1898
1909
|
try {
|
|
1899
1910
|
// AM-5 (fix round 1, apply-leg-never-silent): `detached: true` puts the shell in its OWN
|
|
1900
1911
|
// process GROUP (pgid === its own pid) instead of sharing the caller's — `spawnSync`'s own
|
|
@@ -1925,11 +1936,15 @@ export function probeHookLiveness(
|
|
|
1925
1936
|
process.kill(-res.pid, 'SIGKILL');
|
|
1926
1937
|
} catch {
|
|
1927
1938
|
/* group already gone */
|
|
1939
|
+
} finally {
|
|
1940
|
+
// set right after the process.kill(-pid, 'SIGKILL') attempt (HIGH-1 lead decision): reached
|
|
1941
|
+
// regardless of ESRCH, because ESRCH means "no recipient", not "syscall not issued".
|
|
1942
|
+
groupKillAttempted = true;
|
|
1928
1943
|
}
|
|
1929
1944
|
}
|
|
1930
|
-
return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
|
|
1945
|
+
return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '', groupKillAttempted };
|
|
1931
1946
|
} catch (err) {
|
|
1932
|
-
return { status: null, stdout: '', stderr: String((err as Error)?.message ?? err) };
|
|
1947
|
+
return { status: null, stdout: '', stderr: String((err as Error)?.message ?? err), groupKillAttempted };
|
|
1933
1948
|
}
|
|
1934
1949
|
}
|
|
1935
1950
|
|