@davesheffer/hunch 1.39.2 → 1.40.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.
- package/dist/cli/index.js +154 -64
- package/dist/cli/serve.d.ts +14 -0
- package/dist/cli/serve.js +18 -4
- package/dist/constitution/g2BehaviorCandidates.js +6 -1
- package/dist/constitution/g2Candidates.js +11 -1
- package/dist/constitution/structural.js +10 -0
- package/dist/core/correction.js +12 -7
- package/dist/core/delivery.d.ts +34 -0
- package/dist/core/delivery.js +60 -1
- package/dist/core/docanchors.js +195 -11
- package/dist/core/format.js +6 -1
- package/dist/core/glob.d.ts +25 -0
- package/dist/core/glob.js +43 -0
- package/dist/core/hookcache.d.ts +9 -2
- package/dist/core/hookcache.js +10 -3
- package/dist/core/paths.d.ts +48 -5
- package/dist/core/paths.js +89 -6
- package/dist/core/procstart.d.ts +94 -0
- package/dist/core/procstart.js +244 -0
- package/dist/core/served.d.ts +6 -3
- package/dist/core/served.js +10 -6
- package/dist/core/taskDelivery.js +27 -1
- package/dist/core/taskReport.d.ts +2 -0
- package/dist/core/taskReport.js +46 -11
- package/dist/core/taskReportEvidence.js +17 -1
- package/dist/core/taskReportHook.d.ts +19 -0
- package/dist/core/taskReportHook.js +54 -6
- package/dist/core/verifyLauncher.d.ts +21 -0
- package/dist/core/verifyLauncher.js +37 -0
- package/dist/core/workspace.d.ts +9 -0
- package/dist/core/workspace.js +15 -0
- package/dist/extractors/git.d.ts +5 -0
- package/dist/extractors/git.js +87 -10
- package/dist/extractors/indexer.d.ts +10 -0
- package/dist/extractors/indexer.js +56 -10
- package/dist/extractors/k8sManifest.d.ts +13 -0
- package/dist/extractors/k8sManifest.js +207 -23
- package/dist/extractors/landscapeDiscovery.js +9 -1
- package/dist/extractors/nativeTreeSitter.d.ts +24 -0
- package/dist/extractors/nativeTreeSitter.js +54 -1
- package/dist/extractors/parse.js +6 -1
- package/dist/integrations/claudemd.js +3 -3
- package/dist/integrations/hookmatch.d.ts +25 -0
- package/dist/integrations/hookmatch.js +58 -0
- package/dist/integrations/hooks.d.ts +79 -9
- package/dist/integrations/hooks.js +559 -46
- package/dist/integrations/providers.js +2 -17
- package/dist/integrations/scaffold.d.ts +1 -1
- package/dist/integrations/scaffold.js +24 -21
- package/dist/integrations/workspaceLedger.d.ts +10 -0
- package/dist/integrations/workspaceLedger.js +9 -1
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.js +32 -16
- package/dist/mcp/taskReportTools.d.ts +5 -9
- package/dist/mcp/taskReportTools.js +9 -20
- package/dist/serve/writelock.d.ts +21 -1
- package/dist/serve/writelock.js +157 -16
- package/dist/store/changeLedger.d.ts +9 -3
- package/dist/store/changeLedger.js +36 -10
- package/dist/store/hunchStore.d.ts +42 -2
- package/dist/store/hunchStore.js +105 -17
- package/dist/store/jsonStore.d.ts +6 -4
- package/dist/store/jsonStore.js +225 -32
- package/dist/store/merge.d.ts +7 -4
- package/dist/store/merge.js +65 -5
- package/dist/store/privateMigrate.js +10 -0
- package/dist/wiki/adopt.js +13 -2
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/core/paths.js
CHANGED
|
@@ -1,16 +1,99 @@
|
|
|
1
1
|
/** Filesystem layout for the Hunch (DESIGN.md §6 folder structure). */
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
4
|
-
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
5
|
export const HUNCH_DIR = ".hunch";
|
|
6
|
-
/** Canonicalize a free-form path/target to
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
/** Canonicalize a free-form path/target to forward-slash form (Hunch stores every
|
|
7
|
+
* path with "/" — git emits it on all OSes — so any user- or agent-supplied
|
|
8
|
+
* target must be normalized before comparison, otherwise a Windows caller passing
|
|
9
|
+
* `src\auth\session.ts` never matches the stored `src/auth/session.ts`). Safe on
|
|
10
|
+
* symbol names too: they contain no backslashes. This does NOT make a path
|
|
11
|
+
* repo-relative — an absolute path passes through with its separators flipped,
|
|
12
|
+
* unchanged otherwise; use `repoRelativeTarget` for that. */
|
|
11
13
|
export function toPosixTarget(target) {
|
|
12
14
|
return target.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
13
15
|
}
|
|
16
|
+
/** realpath a path even if it doesn't exist yet (e.g. a new file an agent is about
|
|
17
|
+
* to Write, or a glob whose concrete segments aren't literal): resolve the longest
|
|
18
|
+
* existing ancestor, then re-append the missing tail. Idempotent on an
|
|
19
|
+
* already-resolved path. */
|
|
20
|
+
export function realpathNorm(p) {
|
|
21
|
+
try {
|
|
22
|
+
return realpathSync.native(p);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
const parent = dirname(p);
|
|
26
|
+
if (parent === p)
|
|
27
|
+
return p; // hit the root; nothing more to resolve
|
|
28
|
+
return join(realpathNorm(parent), basename(p));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Rewrite an ABSOLUTE target to repo-relative POSIX form when it falls inside
|
|
32
|
+
* `root`. Hunch's stored paths (symbols, constraint scopes, ...) are always
|
|
33
|
+
* repo-relative, so a caller that hands over an absolute edit-payload path
|
|
34
|
+
* matched nothing (issue #296: hunch_check_constraints / hunch_blast_radius
|
|
35
|
+
* returned empty for an absolute target that plainly had a matching rule). A
|
|
36
|
+
* relative target, a glob, or an absolute path outside `root` passes through
|
|
37
|
+
* unchanged (toPosixTarget'd) — there is nothing safe to rewrite it to.
|
|
38
|
+
*
|
|
39
|
+
* BOTH ends are realpath-normalized before the relative-path computation: on
|
|
40
|
+
* macOS `process.cwd()` (hence `findRoot`) resolves /var → /private/var, but a
|
|
41
|
+
* hook event's `file_path` arrives UN-resolved, so a naive `relative()` yields a
|
|
42
|
+
* bogus "../" path under a symlinked root (/var, /tmp, symlinked $HOME) and the
|
|
43
|
+
* caller treats the file as outside the repo, silently dropping all context
|
|
44
|
+
* (dec_e0a36efbf5). */
|
|
45
|
+
export function repoRelativeTarget(target, root) {
|
|
46
|
+
const t = toPosixTarget(target);
|
|
47
|
+
const looksAbsolute = isAbsolute(t) || /^[a-zA-Z]:/.test(t);
|
|
48
|
+
if (!looksAbsolute)
|
|
49
|
+
return t;
|
|
50
|
+
// `root` is posix'd too, not just `target`: realpathNorm's fallback walk decomposes
|
|
51
|
+
// by "/" (via node:path, which on a POSIX host does NOT treat "\" as a separator),
|
|
52
|
+
// so an un-posix'd root and a posix'd target would decompose into differently-shaped
|
|
53
|
+
// ancestor chains and never share a common realpath'd prefix.
|
|
54
|
+
const rel = toPosixTarget(relative(realpathNorm(toPosixTarget(root)), realpathNorm(t)));
|
|
55
|
+
if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || /^[a-zA-Z]:/.test(rel))
|
|
56
|
+
return t;
|
|
57
|
+
return rel;
|
|
58
|
+
}
|
|
59
|
+
/** True when `target` (already repo-relative POSIX form) names a regular FILE
|
|
60
|
+
* that really exists inside `root`. The LAST-RESORT half of the "is this a real
|
|
61
|
+
* path" question: `isIndexedPath` answers it from graph data alone, but a real
|
|
62
|
+
* working-tree file with zero tree-sitter symbols and no covering component
|
|
63
|
+
* glob is invisible to it, so it fell through to the suffix tier and leaked an
|
|
64
|
+
* unrelated same-basename file's records (issue #334). Callers consult the
|
|
65
|
+
* index FIRST and only fall back here, so a deleted-but-still-indexed path and
|
|
66
|
+
* a time-travel (`asOf`) query keep answering exactly as before.
|
|
67
|
+
*
|
|
68
|
+
* Deliberately narrow: an absolute path (or a Windows drive letter) and any
|
|
69
|
+
* target escaping `root` via ".." are rejected rather than resolved — the
|
|
70
|
+
* caller has already run `repoRelativeTarget`, so anything still absolute is
|
|
71
|
+
* outside the repo. A DIRECTORY is false: directory targets must keep flowing
|
|
72
|
+
* to `structure()`'s dir tier. Any fs error (missing, EACCES, ...) → false.
|
|
73
|
+
*
|
|
74
|
+
* Containment is checked twice: lexically, then again on the REALPATHS. `statSync`
|
|
75
|
+
* follows symlinks, so an in-repo `link -> /outside` would otherwise make
|
|
76
|
+
* `isRepoFile(root, "link/secret.ts")` true and turn this into a one-bit existence
|
|
77
|
+
* oracle for paths outside the repo. A symlinked file pointing at another file
|
|
78
|
+
* INSIDE the repo stays true. */
|
|
79
|
+
export function isRepoFile(root, target) {
|
|
80
|
+
const t = toPosixTarget(target);
|
|
81
|
+
if (!t || isAbsolute(t) || /^[a-zA-Z]:/.test(t))
|
|
82
|
+
return false;
|
|
83
|
+
const abs = resolve(root, t);
|
|
84
|
+
const rel = relative(resolve(root), abs);
|
|
85
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
86
|
+
return false;
|
|
87
|
+
try {
|
|
88
|
+
if (!statSync(abs).isFile())
|
|
89
|
+
return false;
|
|
90
|
+
const realRel = relative(realpathNorm(resolve(root)), realpathSync.native(abs));
|
|
91
|
+
return !!realRel && realRel !== ".." && !realRel.startsWith(`..${sep}`) && !isAbsolute(realRel);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
14
97
|
export function hunchPaths(root) {
|
|
15
98
|
const hunch = join(root, HUNCH_DIR);
|
|
16
99
|
return {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** `starttime` in clock ticks since boot, as the exact decimal STRING the kernel
|
|
2
|
+
* printed — it is an identity component, never a quantity, so it is never
|
|
3
|
+
* converted to a Number (which would lose precision above 2^53 and invite
|
|
4
|
+
* arithmetic). `comm` (field 2) may contain spaces and parentheses, so the
|
|
5
|
+
* numeric fields are only unambiguous AFTER the last `)`. starttime is overall
|
|
6
|
+
* field 22, i.e. index 19 of that remainder (whose [0] is the state field). */
|
|
7
|
+
export declare function starttimeTicks(stat: string): string | null;
|
|
8
|
+
/** Reader seam so the pure token logic is testable off linux. */
|
|
9
|
+
interface ProcReader {
|
|
10
|
+
read: (path: string) => string;
|
|
11
|
+
readlink: (path: string) => string;
|
|
12
|
+
}
|
|
13
|
+
/** The process-instance token `<bootId>:<pidNsInum>:<timeNsInum>:<starttimeTicks>`
|
|
14
|
+
* for `pid` ("self" for us), or `null` when any piece is missing or malformed.
|
|
15
|
+
* Linux only, and no spawn: every piece is a file read.
|
|
16
|
+
*
|
|
17
|
+
* The pid namespace is ALWAYS the caller's own (`/proc/self/ns/pid`), including
|
|
18
|
+
* when tokenizing another pid: a pid we can see in our `/proc` is by definition
|
|
19
|
+
* addressed in our namespace, so that is the namespace the token belongs to.
|
|
20
|
+
*
|
|
21
|
+
* The TIME namespace matters because field 22 is rendered relative to the
|
|
22
|
+
* READER's time namespace: the same live process yields different ticks to
|
|
23
|
+
* readers in different time namespaces, so a token compared across them would
|
|
24
|
+
* read "recycled" for a perfectly healthy owner. Kernels before 5.6 have no
|
|
25
|
+
* `/proc/self/ns/time` at all — that one missing link is normalized to "0"
|
|
26
|
+
* (every process on such a kernel shares the same, only, time namespace).
|
|
27
|
+
*
|
|
28
|
+
* `/proc` itself can be a view from an ANCESTOR pid namespace (a bind-mounted
|
|
29
|
+
* or not-remounted /proc inside a container), in which case `/proc/<pid>` names
|
|
30
|
+
* a process by the ancestor's numbering while `kill(pid, 0)` uses ours — two
|
|
31
|
+
* different pid spaces silently mixed. `readlink /proc/self` returns our pid AS
|
|
32
|
+
* /proc numbers it, so requiring it to equal our own `process.pid` is the cheap
|
|
33
|
+
* proof that the mount we are reading is our own namespace's. */
|
|
34
|
+
export declare function linuxStartToken(pid: number | "self", io?: ProcReader, selfPid?: number): string | null;
|
|
35
|
+
/** OUR process-instance token, or `null` off linux / when /proc proves nothing.
|
|
36
|
+
* Only a NON-NULL token is memoized: a process's own identity never changes,
|
|
37
|
+
* but a transient failure to READ it (a momentarily unreadable /proc) must not
|
|
38
|
+
* be frozen into a permanent "we have no identity" for the life of the process. */
|
|
39
|
+
export declare function selfStartToken(): string | null;
|
|
40
|
+
/** The process-instance token of whoever currently holds `pid`, or `null` when
|
|
41
|
+
* that cannot be established (not linux, pid gone, /proc hidden). */
|
|
42
|
+
export declare function startTokenOf(pid: number): string | null;
|
|
43
|
+
/** What the recorded owner is, relative to whoever holds its pid NOW.
|
|
44
|
+
* - `same` — byte-identical token: the genuine owner, still running.
|
|
45
|
+
* - `recycled` — the pid is held by a DIFFERENT process instance.
|
|
46
|
+
* - `unprobeable` — the pid number carries no meaning here (another boot, another
|
|
47
|
+
* namespace, or a token read on a platform that has none), so
|
|
48
|
+
* neither "alive" nor "dead" is evidence.
|
|
49
|
+
* - `unknown` — no token recorded, or none readable now: not proven either way. */
|
|
50
|
+
export type InstanceVerdict = "same" | "recycled" | "unprobeable" | "unknown";
|
|
51
|
+
/** The whole verdict, pure and injectable. String comparison only — no clocks. */
|
|
52
|
+
export declare function ownerInstance(ownerStart: string | undefined, pid: number, self?: string | null, tokenOf?: (pid: number) => string | null, platform?: NodeJS.Platform): InstanceVerdict;
|
|
53
|
+
/** Nonces of the locks THIS process currently holds.
|
|
54
|
+
*
|
|
55
|
+
* The own-pid fallback for platforms without tokens: when the token path says
|
|
56
|
+
* "unknown" and the lock names OUR OWN pid, a nonce we do not hold means the
|
|
57
|
+
* lock is not one WE took — most likely a PREDECESSOR that happened to have this
|
|
58
|
+
* pid (the container-restart shape) — so the record falls to the age rule
|
|
59
|
+
* instead of our own liveness vouching for a corpse forever.
|
|
60
|
+
*
|
|
61
|
+
* This is module state, i.e. per THREAD. `src/` has no `worker_threads` users
|
|
62
|
+
* (verified by grep), so every holder in a process shares this set; if a worker
|
|
63
|
+
* ever takes these locks, this fallback must be revisited. It is also per MODULE
|
|
64
|
+
* INSTANCE: were this module ever loaded twice in one process (a cache-busting
|
|
65
|
+
* import, a duplicated dependency), one copy would not recognise the other's
|
|
66
|
+
* held nonce and could age-steal its live lock. */
|
|
67
|
+
export declare const heldLockNonces: Set<string>;
|
|
68
|
+
/** What a contender should do about a SAME-HOST lock owner.
|
|
69
|
+
* - `stale` — provably not the owner's process any more: take it over.
|
|
70
|
+
* - `live` — authoritative, however old the file looks: never steal it.
|
|
71
|
+
* - `age` — nothing is proven; fall back to the caller's own age rule. */
|
|
72
|
+
export type SameHostVerdict = "stale" | "live" | "age";
|
|
73
|
+
/** The parts of a lock record this decision reads. Both lock formats supply it. */
|
|
74
|
+
export interface SameHostOwner {
|
|
75
|
+
pid: number;
|
|
76
|
+
start?: string;
|
|
77
|
+
nonce?: string;
|
|
78
|
+
}
|
|
79
|
+
/** Everything the decision consults, injected so every row is unit-testable. */
|
|
80
|
+
export interface SameHostEnv {
|
|
81
|
+
alive: (pid: number) => boolean;
|
|
82
|
+
self: string | null;
|
|
83
|
+
tokenOf: (pid: number) => string | null;
|
|
84
|
+
platform: NodeJS.Platform;
|
|
85
|
+
selfPid: number;
|
|
86
|
+
heldNonces: ReadonlySet<string>;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* THE decision table, shared by `src/serve/writelock.ts` and jsonStore's
|
|
90
|
+
* `.rmw-lock` so the two locks can never drift apart. Pure: no clock, no fs, no
|
|
91
|
+
* process state beyond what `env` hands it.
|
|
92
|
+
*/
|
|
93
|
+
export declare function judgeSameHostOwner(owner: SameHostOwner, env: SameHostEnv): SameHostVerdict;
|
|
94
|
+
export {};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When is a live PID NOT the process that took a lock?
|
|
3
|
+
*
|
|
4
|
+
* Every cross-process lock here trusts a same-host owner while `kill(pid, 0)`
|
|
5
|
+
* succeeds — age alone cannot tell a slow writer from a dead one, so a live PID
|
|
6
|
+
* is authoritative (src/serve/writelock.ts, jsonStore's `.rmw-lock`). That trust
|
|
7
|
+
* has one hole: PIDs are recycled. A container restart routinely hands the new
|
|
8
|
+
* `hunch serve` the SAME pid on the SAME hostname, so a lock left by the dead
|
|
9
|
+
* process names its successor's own pid — provably alive, never taken over, and
|
|
10
|
+
* every write after it refuses forever (issues #287, #293).
|
|
11
|
+
*
|
|
12
|
+
* The disambiguator is IDENTITY, compared by STRING EQUALITY: a lock records the
|
|
13
|
+
* process-instance token of whoever took it, and the current holder of that pid
|
|
14
|
+
* is the same process only if its token is byte-identical. No arithmetic, no
|
|
15
|
+
* ordering, no tolerance — a token either matches or it does not.
|
|
16
|
+
*
|
|
17
|
+
* WHY NOT CLOCKS. The previous draft compared the lock file's mtime against
|
|
18
|
+
* start-time arithmetic. Those are two different clocks: mtime comes from the
|
|
19
|
+
* FILESYSTEM (which on a network or virtualized volume is another machine's),
|
|
20
|
+
* start times from the HOST's boot-relative accounting. Filesystem/host skew, an
|
|
21
|
+
* NTP step forward, or a suspend/resume makes a genuinely live owner look as if
|
|
22
|
+
* it started after its own lock — a false "reused" that STEALS a live lock and
|
|
23
|
+
* puts two writers inside the mutex. Reproduced by simply `utimes`-ing a held
|
|
24
|
+
* lock 30 s into the past. Nothing in this file may read a clock.
|
|
25
|
+
*
|
|
26
|
+
* REMAINING GAP. Only linux exposes a spawn-free, stable process-instance id
|
|
27
|
+
* (`/proc/<pid>/stat` field 22 plus the boot id and the pid/time namespaces). On
|
|
28
|
+
* macOS and Windows a DIFFERENT live process that recycled the owner's pid
|
|
29
|
+
* therefore stays "not proven" and keeps the lock until the age rule applies —
|
|
30
|
+
* today's behaviour, and the safe direction. `ps -o lstart` and PowerShell's
|
|
31
|
+
* `Get-Process ... StartTime` were rejected: both cost a spawn per probe on the
|
|
32
|
+
* contended path, and both return locale/TZ-dependent strings we would have to
|
|
33
|
+
* parse back into a comparable identity. Gating such a probe on age does not
|
|
34
|
+
* rescue them either: the CONTENDER can read the current holder's start time,
|
|
35
|
+
* but a verdict needs something to compare it WITH — either a start time the
|
|
36
|
+
* OWNER recorded when it took the lock (which puts that spawn on every writing
|
|
37
|
+
* process, not just the contended path) or a start-time-vs-mtime comparison,
|
|
38
|
+
* i.e. exactly the cross-clock arithmetic rejected above.
|
|
39
|
+
*
|
|
40
|
+
* UNSUPPORTED TOPOLOGIES. A linux-written token carries its boot id and its pid
|
|
41
|
+
* and time namespaces, so a lock written under another boot or another namespace
|
|
42
|
+
* is now reported as "unprobeable" — the recorded pid number means nothing here,
|
|
43
|
+
* and the age rule governs, exactly as it does for a foreign host. That covers a
|
|
44
|
+
* reboot, two machines sharing a hostname over a network volume, two containers
|
|
45
|
+
* sharing hostname + volume, and a restarted container with a fresh namespace.
|
|
46
|
+
* That "reported as unprobeable" holds only while OUR OWN token is readable: a
|
|
47
|
+
* null self token on linux means our own /proc proved nothing, and those records
|
|
48
|
+
* then get main's pid rule instead of the age rule (no regression from main, but
|
|
49
|
+
* not the age rule either). CRIU checkpoint/restore is NOT supported: a restored
|
|
50
|
+
* process keeps its pid but gets a new boot id / namespace and a new starttime,
|
|
51
|
+
* while our own token is memoized from before the checkpoint — so a restored
|
|
52
|
+
* holder may judge itself against a token that no longer describes it. Tokenless
|
|
53
|
+
* records (a legacy pre-token hunch, any non-linux writer, an unreadable /proc)
|
|
54
|
+
* get main's unchanged pid rule — a live same-host pid keeps its lock, a dead one
|
|
55
|
+
* is stealable — with exactly one exception, the own-pid + foreign-nonce row
|
|
56
|
+
* described in `judgeSameHostOwner`, which gets the AGE rule instead of main's
|
|
57
|
+
* "live forever".
|
|
58
|
+
*/
|
|
59
|
+
import { readFileSync, readlinkSync } from "node:fs";
|
|
60
|
+
/** `starttime` in clock ticks since boot, as the exact decimal STRING the kernel
|
|
61
|
+
* printed — it is an identity component, never a quantity, so it is never
|
|
62
|
+
* converted to a Number (which would lose precision above 2^53 and invite
|
|
63
|
+
* arithmetic). `comm` (field 2) may contain spaces and parentheses, so the
|
|
64
|
+
* numeric fields are only unambiguous AFTER the last `)`. starttime is overall
|
|
65
|
+
* field 22, i.e. index 19 of that remainder (whose [0] is the state field). */
|
|
66
|
+
export function starttimeTicks(stat) {
|
|
67
|
+
const close = stat.lastIndexOf(")");
|
|
68
|
+
if (close < 0)
|
|
69
|
+
return null;
|
|
70
|
+
const ticks = stat.slice(close + 1).trim().split(/\s+/)[19];
|
|
71
|
+
return ticks !== undefined && /^\d+$/.test(ticks) ? ticks : null;
|
|
72
|
+
}
|
|
73
|
+
const realProc = {
|
|
74
|
+
read: (path) => readFileSync(path, "utf8"),
|
|
75
|
+
readlink: (path) => readlinkSync(path),
|
|
76
|
+
};
|
|
77
|
+
/** The process-instance token `<bootId>:<pidNsInum>:<timeNsInum>:<starttimeTicks>`
|
|
78
|
+
* for `pid` ("self" for us), or `null` when any piece is missing or malformed.
|
|
79
|
+
* Linux only, and no spawn: every piece is a file read.
|
|
80
|
+
*
|
|
81
|
+
* The pid namespace is ALWAYS the caller's own (`/proc/self/ns/pid`), including
|
|
82
|
+
* when tokenizing another pid: a pid we can see in our `/proc` is by definition
|
|
83
|
+
* addressed in our namespace, so that is the namespace the token belongs to.
|
|
84
|
+
*
|
|
85
|
+
* The TIME namespace matters because field 22 is rendered relative to the
|
|
86
|
+
* READER's time namespace: the same live process yields different ticks to
|
|
87
|
+
* readers in different time namespaces, so a token compared across them would
|
|
88
|
+
* read "recycled" for a perfectly healthy owner. Kernels before 5.6 have no
|
|
89
|
+
* `/proc/self/ns/time` at all — that one missing link is normalized to "0"
|
|
90
|
+
* (every process on such a kernel shares the same, only, time namespace).
|
|
91
|
+
*
|
|
92
|
+
* `/proc` itself can be a view from an ANCESTOR pid namespace (a bind-mounted
|
|
93
|
+
* or not-remounted /proc inside a container), in which case `/proc/<pid>` names
|
|
94
|
+
* a process by the ancestor's numbering while `kill(pid, 0)` uses ours — two
|
|
95
|
+
* different pid spaces silently mixed. `readlink /proc/self` returns our pid AS
|
|
96
|
+
* /proc numbers it, so requiring it to equal our own `process.pid` is the cheap
|
|
97
|
+
* proof that the mount we are reading is our own namespace's. */
|
|
98
|
+
export function linuxStartToken(pid, io = realProc, selfPid = process.pid) {
|
|
99
|
+
if (pid !== "self" && (!Number.isInteger(pid) || pid <= 0))
|
|
100
|
+
return null;
|
|
101
|
+
try {
|
|
102
|
+
// Validate our /proc VIEW first — it governs every path below, including the
|
|
103
|
+
// "self" one (a foreign view makes even our own token describe someone else).
|
|
104
|
+
if (io.readlink("/proc/self").trim() !== String(selfPid))
|
|
105
|
+
return null;
|
|
106
|
+
const bootId = io.read("/proc/sys/kernel/random/boot_id").trim();
|
|
107
|
+
if (!bootId || !/^[0-9a-fA-F-]+$/.test(bootId))
|
|
108
|
+
return null;
|
|
109
|
+
// `readlink /proc/self/ns/pid` reads `pid:[4026531836]`.
|
|
110
|
+
const pidns = /^pid:\[(\d+)\]$/.exec(io.readlink("/proc/self/ns/pid").trim())?.[1];
|
|
111
|
+
if (pidns === undefined)
|
|
112
|
+
return null;
|
|
113
|
+
let timens;
|
|
114
|
+
try {
|
|
115
|
+
timens = /^time:\[(\d+)\]$/.exec(io.readlink("/proc/self/ns/time").trim())?.[1] ?? "";
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
timens = "0"; // kernel < 5.6: no time namespaces exist, so there is only one
|
|
119
|
+
}
|
|
120
|
+
if (timens === "")
|
|
121
|
+
return null; // the link exists but is not the shape we know
|
|
122
|
+
const ticks = starttimeTicks(io.read(`/proc/${pid === "self" ? "self" : String(pid)}/stat`));
|
|
123
|
+
if (ticks === null)
|
|
124
|
+
return null;
|
|
125
|
+
return `${bootId}:${pidns}:${timens}:${ticks}`;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return null; // no /proc, hidepid, a pid that died mid-read — all unknown
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
let selfToken;
|
|
132
|
+
/** OUR process-instance token, or `null` off linux / when /proc proves nothing.
|
|
133
|
+
* Only a NON-NULL token is memoized: a process's own identity never changes,
|
|
134
|
+
* but a transient failure to READ it (a momentarily unreadable /proc) must not
|
|
135
|
+
* be frozen into a permanent "we have no identity" for the life of the process. */
|
|
136
|
+
export function selfStartToken() {
|
|
137
|
+
if (selfToken === undefined || selfToken === null) {
|
|
138
|
+
const token = process.platform === "linux" ? linuxStartToken("self") : null;
|
|
139
|
+
if (token !== null)
|
|
140
|
+
selfToken = token;
|
|
141
|
+
return token;
|
|
142
|
+
}
|
|
143
|
+
return selfToken;
|
|
144
|
+
}
|
|
145
|
+
/** The process-instance token of whoever currently holds `pid`, or `null` when
|
|
146
|
+
* that cannot be established (not linux, pid gone, /proc hidden). */
|
|
147
|
+
export function startTokenOf(pid) {
|
|
148
|
+
if (process.platform !== "linux")
|
|
149
|
+
return null;
|
|
150
|
+
if (pid === process.pid)
|
|
151
|
+
return selfStartToken();
|
|
152
|
+
return linuxStartToken(pid);
|
|
153
|
+
}
|
|
154
|
+
/** The whole verdict, pure and injectable. String comparison only — no clocks. */
|
|
155
|
+
export function ownerInstance(ownerStart, pid, self = selfStartToken(), tokenOf = startTokenOf, platform = process.platform) {
|
|
156
|
+
// A legacy or non-linux owner recorded no token: today's behaviour, unchanged.
|
|
157
|
+
if (ownerStart === undefined || !/^[0-9a-fA-F-]+:\d+:\d+:\d+$/.test(ownerStart))
|
|
158
|
+
return "unknown";
|
|
159
|
+
if (self === null) {
|
|
160
|
+
// We cannot speak the owner's language. ON linux that only means our own
|
|
161
|
+
// /proc proved nothing (hidepid, no mount, a foreign view): the pre-token pid
|
|
162
|
+
// rule applies unchanged ("unknown"), because handing a live same-host owner
|
|
163
|
+
// to the AGE rule would steal a slow writer's lock that was never stealable
|
|
164
|
+
// before. OFF linux a token-bearing record was written by a DIFFERENT OS
|
|
165
|
+
// instance that merely shares our hostname and volume (a linux container on
|
|
166
|
+
// this machine), so its pid number means nothing here: age governs.
|
|
167
|
+
return platform === "linux" ? "unknown" : "unprobeable";
|
|
168
|
+
}
|
|
169
|
+
// Written under another kernel boot or another pid/time namespace: the recorded
|
|
170
|
+
// pid NUMBER means nothing in ours — a live pid here is a stranger, a dead one
|
|
171
|
+
// is no evidence of the owner's death. Age governs instead.
|
|
172
|
+
const prefix = (token) => token.slice(0, token.lastIndexOf(":"));
|
|
173
|
+
if (prefix(ownerStart) !== prefix(self))
|
|
174
|
+
return "unprobeable";
|
|
175
|
+
const now = tokenOf(pid);
|
|
176
|
+
if (now === null)
|
|
177
|
+
return "unknown"; // died between probes, or /proc hidden for it
|
|
178
|
+
return now === ownerStart ? "same" : "recycled";
|
|
179
|
+
}
|
|
180
|
+
/** Nonces of the locks THIS process currently holds.
|
|
181
|
+
*
|
|
182
|
+
* The own-pid fallback for platforms without tokens: when the token path says
|
|
183
|
+
* "unknown" and the lock names OUR OWN pid, a nonce we do not hold means the
|
|
184
|
+
* lock is not one WE took — most likely a PREDECESSOR that happened to have this
|
|
185
|
+
* pid (the container-restart shape) — so the record falls to the age rule
|
|
186
|
+
* instead of our own liveness vouching for a corpse forever.
|
|
187
|
+
*
|
|
188
|
+
* This is module state, i.e. per THREAD. `src/` has no `worker_threads` users
|
|
189
|
+
* (verified by grep), so every holder in a process shares this set; if a worker
|
|
190
|
+
* ever takes these locks, this fallback must be revisited. It is also per MODULE
|
|
191
|
+
* INSTANCE: were this module ever loaded twice in one process (a cache-busting
|
|
192
|
+
* import, a duplicated dependency), one copy would not recognise the other's
|
|
193
|
+
* held nonce and could age-steal its live lock. */
|
|
194
|
+
export const heldLockNonces = new Set();
|
|
195
|
+
/**
|
|
196
|
+
* THE decision table, shared by `src/serve/writelock.ts` and jsonStore's
|
|
197
|
+
* `.rmw-lock` so the two locks can never drift apart. Pure: no clock, no fs, no
|
|
198
|
+
* process state beyond what `env` hands it.
|
|
199
|
+
*/
|
|
200
|
+
export function judgeSameHostOwner(owner, env) {
|
|
201
|
+
// 1. A lock THIS process holds is never stolen — not by a recycled-pid guess,
|
|
202
|
+
// not by age. Our own held nonce outranks every other signal.
|
|
203
|
+
if (owner.nonce !== undefined && env.heldNonces.has(owner.nonce))
|
|
204
|
+
return "live";
|
|
205
|
+
const verdict = ownerInstance(owner.start, owner.pid, env.self, env.tokenOf, env.platform);
|
|
206
|
+
// 2. The pid number means nothing under this boot/namespace: neither its life
|
|
207
|
+
// nor its death is evidence. The caller's age rule governs, as for a
|
|
208
|
+
// foreign host.
|
|
209
|
+
if (verdict === "unprobeable")
|
|
210
|
+
return "age";
|
|
211
|
+
// 3. A dead pid whose number IS meaningful here: the owner is gone.
|
|
212
|
+
if (!env.alive(owner.pid))
|
|
213
|
+
return "stale";
|
|
214
|
+
// 4. Alive, but a different process instance holds that pid (the recycled-pid
|
|
215
|
+
// hole, proven by string equality).
|
|
216
|
+
if (verdict === "recycled")
|
|
217
|
+
return "stale";
|
|
218
|
+
// 5. Alive and byte-identical: the genuine owner, however old the file looks.
|
|
219
|
+
if (verdict === "same")
|
|
220
|
+
return "live";
|
|
221
|
+
// 6. "unknown": no token on one side or the other. A live pid is never stolen
|
|
222
|
+
// on a guess, with one exception — a record naming OUR OWN pid whose nonce
|
|
223
|
+
// we do NOT hold (row 1 already returned for one we do). That is the
|
|
224
|
+
// container-restart shape: a predecessor with this pid left the lock, we
|
|
225
|
+
// are alive, and nobody would ever take it over.
|
|
226
|
+
//
|
|
227
|
+
// WHY "age" AND NOT AN IMMEDIATE STEAL, ON EVERY PLATFORM. A nonce we do
|
|
228
|
+
// not hold proves only that the record is not THIS process's lock — NOT
|
|
229
|
+
// that whoever wrote it is dead. "Our pid" can be a LIVE NEIGHBOUR's in
|
|
230
|
+
// another pid space that happens to share our hostname and our volume: on
|
|
231
|
+
// linux, two containers sharing hostname + volume but not the pid namespace
|
|
232
|
+
// (k8s sidecars, `--net=host`); off linux, a linux GUEST of this machine
|
|
233
|
+
// (WSL2 shares the Windows hostname and /mnt/c by default; Docker Desktop
|
|
234
|
+
// bind mounts do the same). main never stole this shape at all — that is
|
|
235
|
+
// issue #287's forever-deadlock — so the age rule is what ends the deadlock,
|
|
236
|
+
// within the caller's own stale window, without ever stealing a FRESH lock
|
|
237
|
+
// that a live writer may still hold.
|
|
238
|
+
if (owner.pid === env.selfPid && owner.nonce !== undefined)
|
|
239
|
+
return "age";
|
|
240
|
+
// A nonce-less own-pid record stays live: there is nothing to compare, and a
|
|
241
|
+
// live pid is never stolen on a guess.
|
|
242
|
+
return "live";
|
|
243
|
+
}
|
|
244
|
+
//# sourceMappingURL=procstart.js.map
|
package/dist/core/served.d.ts
CHANGED
|
@@ -47,9 +47,12 @@ export interface ServedSummary {
|
|
|
47
47
|
rows: ServedRow[];
|
|
48
48
|
recent: ServedReceipt[];
|
|
49
49
|
}
|
|
50
|
-
/** Shared observation ledger. Explicit report operations surface failures
|
|
51
|
-
* passive delivery/hook callers
|
|
52
|
-
|
|
50
|
+
/** Shared observation ledger. Explicit report operations surface failures and may
|
|
51
|
+
* pass a longer wait for a competing writer; passive delivery/hook callers keep the
|
|
52
|
+
* 100 ms default and may degrade to best-effort recording. */
|
|
53
|
+
export declare function withServedDatabase<T>(root: string, run: (db: DatabaseSync) => T, options?: {
|
|
54
|
+
busyTimeoutMs?: number;
|
|
55
|
+
}): T;
|
|
53
56
|
/** Append delivery receipts. Never throws — a receipt must never cost a delivery. */
|
|
54
57
|
export declare function recordServed(root: string, entries: readonly ServedEntry[]): void;
|
|
55
58
|
/** The ledger, aggregated per record. Never throws; an unreadable ledger reads as empty. */
|
package/dist/core/served.js
CHANGED
|
@@ -67,12 +67,15 @@ function ensureReceiptColumns(db) {
|
|
|
67
67
|
existing.add(name);
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
-
function openServedDb(root) {
|
|
70
|
+
function openServedDb(root, busyTimeoutMs = 100) {
|
|
71
71
|
sqlite ??= loadSqlite();
|
|
72
72
|
const dir = join(root, ".hunch-cache");
|
|
73
73
|
mkdirSync(dir, { recursive: true });
|
|
74
74
|
const db = new sqlite.DatabaseSync(join(dir, "served.db"));
|
|
75
|
-
|
|
75
|
+
// Rollback-journal mode is deliberate: WAL would create and delete -wal/-shm
|
|
76
|
+
// siblings on every open, which file watchers on `.hunch-cache/served.db*` see
|
|
77
|
+
// as ledger changes.
|
|
78
|
+
db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.trunc(busyTimeoutMs))}`);
|
|
76
79
|
db.exec(`CREATE TABLE IF NOT EXISTS served (
|
|
77
80
|
at TEXT NOT NULL,
|
|
78
81
|
session TEXT,
|
|
@@ -85,11 +88,12 @@ function openServedDb(root) {
|
|
|
85
88
|
ensureReceiptColumns(db);
|
|
86
89
|
return db;
|
|
87
90
|
}
|
|
88
|
-
/** Shared observation ledger. Explicit report operations surface failures
|
|
89
|
-
* passive delivery/hook callers
|
|
90
|
-
|
|
91
|
+
/** Shared observation ledger. Explicit report operations surface failures and may
|
|
92
|
+
* pass a longer wait for a competing writer; passive delivery/hook callers keep the
|
|
93
|
+
* 100 ms default and may degrade to best-effort recording. */
|
|
94
|
+
export function withServedDatabase(root, run, options = {}) {
|
|
91
95
|
assertReportPath(root, ".hunch-cache", "served.db");
|
|
92
|
-
const db = openServedDb(root);
|
|
96
|
+
const db = openServedDb(root, options.busyTimeoutMs);
|
|
93
97
|
try {
|
|
94
98
|
return run(db);
|
|
95
99
|
}
|
|
@@ -39,15 +39,41 @@ export function taskSelectionSupplements(selection, target) {
|
|
|
39
39
|
const parts = selection.mode === "latest"
|
|
40
40
|
? `latest ${counts.latest} (ranking off: it lost its evaluation; hunch task rank-eval)`
|
|
41
41
|
: [counts.latest ? "latest" : null, counts.violation ? "problem" : null, counts.relevant ? `relevant ${counts.relevant}` : null].filter(Boolean).join(" · ");
|
|
42
|
+
// `hash_text`: the IDENTITY of the records this selection picked, for the
|
|
43
|
+
// pre-edit hook's injection dedup. Every presentation field here is volatile
|
|
44
|
+
// between two back-to-back calls with no record change — the slot label and
|
|
45
|
+
// counts move with ranking warmth, and the reason text flips ("today" →
|
|
46
|
+
// "delivered today", or to a different top reason) because serving the block
|
|
47
|
+
// writes delivery receipts the next call's ranking reads back. Hashing the
|
|
48
|
+
// rendered line therefore made the block self-invalidating and re-sent the
|
|
49
|
+
// full 3-4KB grounding.
|
|
50
|
+
//
|
|
51
|
+
// What the identity KEEPS, because each is a property of the records, the
|
|
52
|
+
// repo or the evaluation state and never of a receipt:
|
|
53
|
+
// - header: the target, the picked set of record ids (sorted, so order is
|
|
54
|
+
// not a change), `selection.mode` (ranker vs the "latest" fallback the
|
|
55
|
+
// kill rule imposes — resolved from .hunch/local.json or the rank-eval
|
|
56
|
+
// report), and `selection.more` (gated candidates minus picks; the gates
|
|
57
|
+
// read superseded ids, anchor liveness and file/rule structure — scores
|
|
58
|
+
// only order them, so the count does not move with warmth);
|
|
59
|
+
// - per task: its id, its own content hash, and whether its file anchors
|
|
60
|
+
// are still all alive (`anchorsAlive < 1` — the fact behind the "files
|
|
61
|
+
// since changed" reason), so a picked task whose anchors die mid-session
|
|
62
|
+
// re-sends the full block instead of leaving a silently stale line.
|
|
42
63
|
return [
|
|
43
64
|
{
|
|
44
65
|
id: "recent-tasks", kind: "recent-tasks", priority: 415,
|
|
45
66
|
text: `RECENT TASKS on ${target} — ${parts} — earlier agent work here, from graph memory (advisory history, not rules): build on what was verified instead of redoing it blind.${selection.more > 0 ? ` ${selection.more} more: hunch task list ${target}.` : ""}`,
|
|
67
|
+
hash_text: `recent-tasks ${target} ${[...selection.picks.map((p) => p.ranked.record.id)].sort().join(",")} mode=${selection.mode ?? "ranked"} more=${selection.more}`,
|
|
46
68
|
},
|
|
47
69
|
...selection.picks.map((p, i) => {
|
|
48
70
|
const t = p.ranked.record;
|
|
49
71
|
const reasons = p.ranked.reasons.slice(0, 2).join(" · ");
|
|
50
|
-
return {
|
|
72
|
+
return {
|
|
73
|
+
id: t.id, kind: "recent-task", priority: 414 - i,
|
|
74
|
+
text: `${SLOT_LABEL[p.slot]} ${t.id} · ${t.finished_at.slice(0, 10)} · "${clip(t.title, 80)}" — ${reasons} · ${summarizeRecord(t)}`,
|
|
75
|
+
hash_text: `${t.id}@${t.report_hash}${p.ranked.anchorsAlive < 1 ? "!stale" : ""}`,
|
|
76
|
+
};
|
|
51
77
|
}),
|
|
52
78
|
];
|
|
53
79
|
}
|
|
@@ -206,6 +206,8 @@ export interface LessonHistory {
|
|
|
206
206
|
export declare function aliasReportTask(root: string, aliasId: string, taskId: string): void;
|
|
207
207
|
/** The task an aliased prompt identity reports to, or the identity itself. */
|
|
208
208
|
export declare function resolveReportTask(root: string, id: string): string;
|
|
209
|
+
/** Whether a task id names a real task of THIS repository/worktree. */
|
|
210
|
+
export declare function reportTaskExists(root: string, id: string): boolean;
|
|
209
211
|
/** The lookup is derived, never evidence. Backfill at most 64 events and
|
|
210
212
|
* 512 KB per read. Parse outside the writer transaction so a history view cannot
|
|
211
213
|
* hold the writer lock while validating a large legacy ledger. */
|