@brainervirus/workit-core 8.13.0 → 8.14.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/package.json +1 -1
- package/scripts/rewrite-workspace-deps.ts +3 -3
- package/skills/workit-fanout/SKILL.md +4 -4
- package/skills/workit-fanout/references/orchestration.md +8 -5
- package/skills/workit-review/SKILL.md +1 -1
- package/src/core/hygiene.ts +37 -1
- package/src/core/live-session.ts +333 -0
- package/src/core/store-lock.ts +9 -1
- package/src/core/workspaces.ts +135 -15
- package/src/fanout-brief.ts +8 -0
- package/src/fanout-status.ts +18 -2
- package/src/forge/pr-ops.ts +84 -18
- package/src/forge/redact.ts +6 -2
- package/src/forge/resolve.ts +15 -0
- package/src/git/ops.ts +20 -0
- package/src/hooks/handle.ts +13 -9
- package/src/hooks/index.ts +2 -1
- package/src/hooks/stop.ts +4 -2
- package/src/hooks/write-gate.ts +303 -3
- package/src/host-session.ts +137 -27
- package/src/ledger.ts +280 -24
- package/src/stack.ts +2 -2
package/package.json
CHANGED
|
@@ -29,9 +29,9 @@ for (const pkg of [
|
|
|
29
29
|
]) {
|
|
30
30
|
const file = resolve(root, `packages/${pkg}/package.json`);
|
|
31
31
|
const data = JSON.parse(readFileSync(file, "utf8"));
|
|
32
|
-
// devDependencies too:
|
|
33
|
-
// dist/, so they are build-time only
|
|
34
|
-
// never carry workspace:*.
|
|
32
|
+
// devDependencies too: every adapter bundles core (and the MCP server) into
|
|
33
|
+
// dist/, so they are build-time only (N19) and npm never installs them, but
|
|
34
|
+
// a published manifest must still never carry workspace:*.
|
|
35
35
|
const groups = [data.dependencies, data.devDependencies].filter(Boolean);
|
|
36
36
|
if (groups.length === 0) continue;
|
|
37
37
|
for (const deps of groups)
|
|
@@ -25,9 +25,9 @@ ceremony; do it yourself. Workit never spawns agents; you do.
|
|
|
25
25
|
it; change the plan or the standing orders and render again.
|
|
26
26
|
4. **Spawn in a rolling window**, in the background, each in its own worktree:
|
|
27
27
|
keep 4-6 in flight and refill from `spawnable` in `workit fanout status` as
|
|
28
|
-
each one finishes.
|
|
29
|
-
Claude Code: the `implementer` agent;
|
|
30
|
-
worktree create <slice>`.
|
|
28
|
+
each one finishes. Model: the brief's `model:` line, else TIER
|
|
29
|
+
(`references/orchestration.md`). Claude Code: the `implementer` agent;
|
|
30
|
+
elsewhere first `workit fanout worktree create <slice>`.
|
|
31
31
|
5. **Judge liveness by side effects only:** `workit fanout status` (head age,
|
|
32
32
|
PR, CI, verdict, landed; STUCK past the TIMEBOX). Stop a stuck worker and
|
|
33
33
|
observe that it exited (a timeout is not proof). Removing a worktree drops
|
|
@@ -53,7 +53,7 @@ ceremony; do it yourself. Workit never spawns agents; you do.
|
|
|
53
53
|
status` order, release the worktrees you made, and `workit ledger standing
|
|
54
54
|
clear`. Only you touch topology: workers never rebase, retarget or merge,
|
|
55
55
|
except the integration-tip merge in integration mode. Then ship
|
|
56
|
-
(workit-ship)
|
|
56
|
+
(workit-ship) up to `defaultEndpoint` from `workit grant show`.
|
|
57
57
|
|
|
58
58
|
## Example
|
|
59
59
|
|
|
@@ -5,8 +5,10 @@ and fans in. Workit records and checks; the host's own subagents run the work.
|
|
|
5
5
|
|
|
6
6
|
## TIER to model
|
|
7
7
|
|
|
8
|
-
TIER is a field of each slice and of its brief.
|
|
9
|
-
|
|
8
|
+
TIER is a field of each slice and of its brief. When the workspace sets
|
|
9
|
+
`fanout.tierModels`, the brief has a `model:` line under TIER and `workit
|
|
10
|
+
fanout status` names the model next to each spawnable slice: use that model.
|
|
11
|
+
Without one, map TIER where the host lets you pick a model per spawn:
|
|
10
12
|
|
|
11
13
|
| TIER | Claude Code (`model` on the Agent call) | Hosts without a per-spawn model |
|
|
12
14
|
| --- | --- | --- |
|
|
@@ -72,11 +74,12 @@ Which session id a verdict carries, per host:
|
|
|
72
74
|
|
|
73
75
|
- Claude Code: subagents inherit your `WORKIT_SESSION_ID`, so the
|
|
74
76
|
SubagentStart hook names each `verifier` and `reviewer` its own session
|
|
75
|
-
(`<lead>:<agent id>`)
|
|
76
|
-
|
|
77
|
+
(`<lead>:<agent id>`) and a token only that subagent sees, passed exactly as
|
|
78
|
+
`--session` and `--token`: the hook records the ids it issues, and only
|
|
79
|
+
those, with their token, are accepted. An `implementer` is issued
|
|
77
80
|
none: it keeps your id in its env for its workit commands and commits.
|
|
78
81
|
- Codex: the `workit-verifier` and `workit-reviewer` agents get the same
|
|
79
|
-
issued `--session` from SubagentStart; any other verifier passes
|
|
82
|
+
issued `--session` and `--token` from SubagentStart; any other verifier passes
|
|
80
83
|
`--as verifier`.
|
|
81
84
|
- OpenCode: a child session's shell already has
|
|
82
85
|
`WORKIT_SESSION_ID=<lead>:<child>`; Pi: a supervised worker's shell has
|
|
@@ -8,7 +8,7 @@ description: Independent review of a diff, branch or PR - intent fidelity and st
|
|
|
8
8
|
Review the candidate, never the author's summary of it. A session that wrote a
|
|
9
9
|
commit on the branch cannot record a passing verdict: if that is you, hand the
|
|
10
10
|
review to a fresh agent with its own host-derived session: Claude Code and
|
|
11
|
-
Codex, the `reviewer` or `verifier` agent (`--session` from SubagentStart);
|
|
11
|
+
Codex, the `reviewer` or `verifier` agent (`--session` and `--token` from SubagentStart);
|
|
12
12
|
OpenCode, a child session; Pi, a supervised worker. Never hand-mint one such
|
|
13
13
|
as `<lead>-v1`: it is refused as `forged_session`.
|
|
14
14
|
An author's `--self` verdict reads self-reviewed and never counts as
|
package/src/core/hygiene.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { changelogUnreleasedStats } from "./changelog";
|
|
4
5
|
import { assetRoot } from "./package-root";
|
|
@@ -75,6 +76,41 @@ export const hygieneFiles = (
|
|
|
75
76
|
return { state, openSource };
|
|
76
77
|
};
|
|
77
78
|
|
|
79
|
+
export type ProjectTarget = { ok: true; root: string } | { ok: false; refused: string };
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Where project files (gitignore, hygiene) belong: the git toplevel of `dir`.
|
|
83
|
+
* Outside a repository there is no project to scaffold, and a folder holding
|
|
84
|
+
* several projects must never receive them, so the answer is a refusal naming
|
|
85
|
+
* the way out: the child repositories found one level down, or `cd <repo>`.
|
|
86
|
+
*/
|
|
87
|
+
export const projectTarget = (dir: string): ProjectTarget => {
|
|
88
|
+
const start = path.resolve(dir);
|
|
89
|
+
const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: start, encoding: "utf8" });
|
|
90
|
+
const root = top.status === 0 ? (top.stdout ?? "").trim() : "";
|
|
91
|
+
if (root) return { ok: true, root: path.resolve(root) };
|
|
92
|
+
let children: string[] = [];
|
|
93
|
+
try {
|
|
94
|
+
children = readdirSync(start, { withFileTypes: true })
|
|
95
|
+
.filter((entry) => entry.isDirectory() && existsSync(path.join(start, entry.name, ".git")))
|
|
96
|
+
.map((entry) => path.join(start, entry.name))
|
|
97
|
+
.toSorted();
|
|
98
|
+
} catch {
|
|
99
|
+
children = [];
|
|
100
|
+
}
|
|
101
|
+
const where =
|
|
102
|
+
children.length > 0
|
|
103
|
+
? `run it inside one of its repositories: ${children
|
|
104
|
+
.slice(0, 5)
|
|
105
|
+
.map((child) => `cd ${child}`)
|
|
106
|
+
.join(", ")}${children.length > 5 ? ` (+${children.length - 5} more)` : ""}`
|
|
107
|
+
: "cd <repo> (or git init here) and run workit init again";
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
refused: `${start} is not a git repository, so no project files were added there; ${where}`,
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
|
|
78
114
|
// Pure planner: which hygiene files would be created and with what content,
|
|
79
115
|
// without touching the filesystem. Drives the wizard preview (WZ-08) and the
|
|
80
116
|
// apply path; ensureHygieneFiles is just "plan then write".
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Live-session markers (#274): which agent session last wrote in a checkout,
|
|
2
|
+
// so another session's write gate can refuse to edit a checkout a live agent
|
|
3
|
+
// owns. One marker per checkout (a worktree's top level: worktrees of one
|
|
4
|
+
// repository are distinct checkouts) in the per-user state dir, so nothing is
|
|
5
|
+
// written into a repository Workit does not manage:
|
|
6
|
+
// `<state>/live-sessions/<hash of the checkout>.json`, holding the owner's
|
|
7
|
+
// session, lead, host, process id, machine and heartbeat. The hooks each host
|
|
8
|
+
// already runs write it on every allowed edit or mutating command.
|
|
9
|
+
//
|
|
10
|
+
// A marker is live while its heartbeat is younger than LIVE_SESSION_TTL_MS
|
|
11
|
+
// and, on the same machine, its process still runs. The owning session, its
|
|
12
|
+
// subagents (`<lead>:<agent>`, `<lead>-w-<slice>`, a parent session) and any
|
|
13
|
+
// session in the same host process are never refused. Every state error reads
|
|
14
|
+
// as "no marker": the guard fails open.
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { resolveStore } from "../store/paths";
|
|
21
|
+
import { resolveStateDir } from "./logger";
|
|
22
|
+
import { processStartOf } from "./store-lock";
|
|
23
|
+
|
|
24
|
+
export const LIVE_SESSION_TTL_MS = 15 * 60_000;
|
|
25
|
+
/** Rewrite a live marker of one's own at most this often. */
|
|
26
|
+
const HEARTBEAT_EVERY_MS = 30_000;
|
|
27
|
+
|
|
28
|
+
export type LiveOwner = {
|
|
29
|
+
host: string;
|
|
30
|
+
session: string;
|
|
31
|
+
/** Lead ids this session counts as (its own lead, its parent's). */
|
|
32
|
+
leads: string[];
|
|
33
|
+
/**
|
|
34
|
+
* The host process (null when it cannot be told), read lazily: off Linux
|
|
35
|
+
* it costs `ps` calls, so only a write or a foreign marker asks for it.
|
|
36
|
+
*/
|
|
37
|
+
pid: () => number | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type LiveMarker = {
|
|
41
|
+
version: 1;
|
|
42
|
+
session: string;
|
|
43
|
+
leads: string[];
|
|
44
|
+
host: string;
|
|
45
|
+
pid: number | null;
|
|
46
|
+
/** The pid's start time, so a reused pid does not keep the marker alive. */
|
|
47
|
+
pidStart: string | null;
|
|
48
|
+
machine: string;
|
|
49
|
+
checkout: string;
|
|
50
|
+
heartbeatAt: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type LiveDeps = {
|
|
54
|
+
now?: Date;
|
|
55
|
+
alive?: (pid: number) => boolean;
|
|
56
|
+
machine?: string;
|
|
57
|
+
ttlMs?: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What reads the process table; tests swap it to count `ps` calls or to act
|
|
62
|
+
* as another platform.
|
|
63
|
+
*/
|
|
64
|
+
export const liveProbe = {
|
|
65
|
+
platform: process.platform as string,
|
|
66
|
+
run: (args: string[]): string =>
|
|
67
|
+
spawnSync("ps", args, { encoding: "utf8", timeout: 2_000 }).stdout ?? "",
|
|
68
|
+
start: (pid: number): string | null => processStartOf(pid),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** A session's lead: the part before `:` (an issued subagent id), minus a fanout `-w-<slice>` tail. */
|
|
72
|
+
export const leadOf = (session: string): string =>
|
|
73
|
+
session.split(":")[0].replace(/-w-.*$/su, "") || session;
|
|
74
|
+
|
|
75
|
+
const familyOf = (host: string): string => host.replace(/_(?:cli|desktop)$/u, "");
|
|
76
|
+
|
|
77
|
+
const processAlive = (pid: number): boolean => {
|
|
78
|
+
try {
|
|
79
|
+
process.kill(pid, 0);
|
|
80
|
+
return true;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Parent pid and command line of `pid`: /proc on Linux, `ps` on other POSIX systems. */
|
|
87
|
+
const processInfo = (pid: number): { ppid: number; command: string } | null => {
|
|
88
|
+
try {
|
|
89
|
+
if (liveProbe.platform === "linux") {
|
|
90
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
91
|
+
const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
|
|
92
|
+
const command = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").replaceAll("\0", " ");
|
|
93
|
+
return Number.isInteger(ppid) ? { ppid, command: command.trim() } : null;
|
|
94
|
+
}
|
|
95
|
+
if (liveProbe.platform === "win32") return null;
|
|
96
|
+
const match = /^\s*(\d+)\s+(.*)$/su.exec(
|
|
97
|
+
liveProbe.run(["-o", "ppid=,command=", "-p", String(pid)]),
|
|
98
|
+
);
|
|
99
|
+
return match ? { ppid: Number(match[1]), command: match[2].trim() } : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** A process that only launches the hook and exits with it: a shell, npx, the Cursor launcher. */
|
|
106
|
+
const WRAPPER =
|
|
107
|
+
/^(?:\S*\/)?(?:sh|bash|dash|zsh|ash|ksh|fish|env|timeout|npx|npm|bunx|cmd\.exe|pwsh|powershell)(?:\s|$)|launch\.mjs|npx-cli\.js/u;
|
|
108
|
+
|
|
109
|
+
const PIDS = new Map<string, number | null>();
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The long-lived host process behind this hook: the plugin's own process for
|
|
113
|
+
* an in-process host (OpenCode, Pi); for a hook process (Claude Code, Codex,
|
|
114
|
+
* Cursor), the nearest ancestor that is not a launcher wrapper. Null when the
|
|
115
|
+
* ancestry cannot be read (Windows). Cached per process.
|
|
116
|
+
*/
|
|
117
|
+
export const hostPid = (transport: string): number | null => {
|
|
118
|
+
if (transport !== "hook-process") return process.pid;
|
|
119
|
+
const key = `${liveProbe.platform}\0${process.ppid}`;
|
|
120
|
+
if (PIDS.has(key)) return PIDS.get(key) ?? null;
|
|
121
|
+
// Windows: no ancestry to walk, so no owner pid at all.
|
|
122
|
+
if (liveProbe.platform === "win32") {
|
|
123
|
+
PIDS.set(key, null);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
let pid = process.ppid;
|
|
127
|
+
for (let hop = 0; hop < 4; hop++) {
|
|
128
|
+
const info = processInfo(pid);
|
|
129
|
+
if (!info || !WRAPPER.test(info.command) || info.ppid <= 1) break;
|
|
130
|
+
pid = info.ppid;
|
|
131
|
+
}
|
|
132
|
+
PIDS.set(key, pid);
|
|
133
|
+
return pid;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const sessionSafe = (value: string): string => value.replace(/[^A-Za-z0-9_.:@/+-]/gu, "-");
|
|
137
|
+
|
|
138
|
+
/** The owner a hook acts for, or null without a session id (never refused, never recorded). */
|
|
139
|
+
export const liveOwner = (
|
|
140
|
+
input: { host: string; session: { id: string; parentId: string | null } },
|
|
141
|
+
transport: string,
|
|
142
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
143
|
+
): LiveOwner | null => {
|
|
144
|
+
const session = input.session.id?.trim();
|
|
145
|
+
if (!session) return null;
|
|
146
|
+
const leads = new Set([leadOf(sessionSafe(session))]);
|
|
147
|
+
if (input.session.parentId) leads.add(leadOf(sessionSafe(input.session.parentId)));
|
|
148
|
+
// A Pi worker's hooks see its own Pi session; its workit id names the lead.
|
|
149
|
+
if (env.WORKIT_PI_WORKER_SESSION && env.WORKIT_SESSION_ID)
|
|
150
|
+
leads.add(leadOf(sessionSafe(env.WORKIT_SESSION_ID)));
|
|
151
|
+
return {
|
|
152
|
+
host: input.host,
|
|
153
|
+
session: sessionSafe(session),
|
|
154
|
+
leads: [...leads],
|
|
155
|
+
pid: () => hostPid(transport),
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** The checkout (worktree top level) holding `target`; null outside git. */
|
|
160
|
+
const checkoutOf = (target: string): string | null => {
|
|
161
|
+
let dir = path.resolve(target);
|
|
162
|
+
// A path that does not exist yet: its nearest existing ancestor.
|
|
163
|
+
while (!fs.existsSync(dir) && path.dirname(dir) !== dir) dir = path.dirname(dir);
|
|
164
|
+
if (fs.existsSync(dir) && !fs.statSync(dir).isDirectory()) dir = path.dirname(dir);
|
|
165
|
+
// Inside a `.git` directory: the checkout it belongs to.
|
|
166
|
+
const parts = dir.split(path.sep);
|
|
167
|
+
const dotGit = parts.lastIndexOf(".git");
|
|
168
|
+
if (dotGit > 0) dir = parts.slice(0, dotGit).join(path.sep) || path.sep;
|
|
169
|
+
const location = resolveStore(dir);
|
|
170
|
+
if (location instanceof Error || !location.shared || !location.top) return null;
|
|
171
|
+
let top = location.top;
|
|
172
|
+
try {
|
|
173
|
+
top = fs.realpathSync.native(top);
|
|
174
|
+
} catch {
|
|
175
|
+
// keep git's spelling
|
|
176
|
+
}
|
|
177
|
+
return top;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** The marker file of a checkout. */
|
|
181
|
+
export const markerFile = (top: string): string =>
|
|
182
|
+
path.join(
|
|
183
|
+
resolveStateDir(),
|
|
184
|
+
"live-sessions",
|
|
185
|
+
`${createHash("sha256").update(checkoutKey(top)).digest("hex").slice(0, 32)}.json`,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* One spelling per checkout, however the path was written: resolved through
|
|
190
|
+
* symlinks and Windows 8.3 short names (`RUNNER~1`), native separators, and
|
|
191
|
+
* on Windows a lower-cased path (drive letter included).
|
|
192
|
+
*/
|
|
193
|
+
export const checkoutKey = (top: string, platform: string = process.platform): string => {
|
|
194
|
+
let key = path.resolve(top);
|
|
195
|
+
try {
|
|
196
|
+
key = fs.realpathSync.native(key);
|
|
197
|
+
} catch {
|
|
198
|
+
// a path that does not exist keeps its resolved spelling
|
|
199
|
+
}
|
|
200
|
+
return platform === "win32" ? key.replaceAll("/", "\\").toLowerCase() : key;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const readMarker = (file: string): LiveMarker | null => {
|
|
204
|
+
try {
|
|
205
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<LiveMarker>;
|
|
206
|
+
if (
|
|
207
|
+
value?.version !== 1 ||
|
|
208
|
+
typeof value.session !== "string" ||
|
|
209
|
+
typeof value.host !== "string" ||
|
|
210
|
+
typeof value.heartbeatAt !== "string" ||
|
|
211
|
+
Number.isNaN(Date.parse(value.heartbeatAt))
|
|
212
|
+
)
|
|
213
|
+
return null;
|
|
214
|
+
return {
|
|
215
|
+
version: 1,
|
|
216
|
+
session: value.session,
|
|
217
|
+
leads: Array.isArray(value.leads)
|
|
218
|
+
? value.leads.filter((item) => typeof item === "string")
|
|
219
|
+
: [],
|
|
220
|
+
host: value.host,
|
|
221
|
+
pid: typeof value.pid === "number" ? value.pid : null,
|
|
222
|
+
pidStart: typeof value.pidStart === "string" ? value.pidStart : null,
|
|
223
|
+
machine: typeof value.machine === "string" ? value.machine : "",
|
|
224
|
+
checkout: typeof value.checkout === "string" ? value.checkout : "",
|
|
225
|
+
heartbeatAt: value.heartbeatAt,
|
|
226
|
+
};
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const ageOf = (marker: LiveMarker, now: Date): number =>
|
|
233
|
+
now.getTime() - Date.parse(marker.heartbeatAt);
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Whether the marker's owner still counts: a fresh heartbeat and, on this
|
|
237
|
+
* machine, a running process that is still the one recorded (same start time).
|
|
238
|
+
*/
|
|
239
|
+
const isLive = (marker: LiveMarker, deps: LiveDeps): boolean => {
|
|
240
|
+
const age = ageOf(marker, deps.now ?? new Date());
|
|
241
|
+
if (age > (deps.ttlMs ?? LIVE_SESSION_TTL_MS)) return false;
|
|
242
|
+
if (marker.machine !== (deps.machine ?? os.hostname()) || marker.pid === null) return true;
|
|
243
|
+
if (!(deps.alive ?? processAlive)(marker.pid)) return false;
|
|
244
|
+
if (marker.pidStart === null) return true;
|
|
245
|
+
const start = liveProbe.start(marker.pid);
|
|
246
|
+
return start === null || start === marker.pidStart;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** The same session, one of its subagents or fanout workers, or its lead: ids alone, no I/O. */
|
|
250
|
+
const sameLead = (marker: LiveMarker, owner: LiveOwner): boolean =>
|
|
251
|
+
marker.session === owner.session ||
|
|
252
|
+
owner.leads.some((lead) => lead === marker.session || marker.leads.includes(lead));
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The same session, one of its subagents or its lead, or another session of
|
|
256
|
+
* the same host process. Where a pid is unknown (Windows), any session of the
|
|
257
|
+
* same host family counts as the owner: the guard cannot tell a subagent or a
|
|
258
|
+
* cleared session from another window there, and never denies on a guess.
|
|
259
|
+
*/
|
|
260
|
+
export const sameOwner = (marker: LiveMarker, owner: LiveOwner, machine: string): boolean => {
|
|
261
|
+
if (sameLead(marker, owner)) return true;
|
|
262
|
+
if (marker.machine !== machine) return false;
|
|
263
|
+
const pid = owner.pid();
|
|
264
|
+
// Without a pid and its start time the process cannot be told apart.
|
|
265
|
+
if (pid === null || marker.pid === null || marker.pidStart === null)
|
|
266
|
+
return familyOf(marker.host) === familyOf(owner.host);
|
|
267
|
+
return marker.pid === pid;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
export type LiveConflict = { marker: LiveMarker; file: string; ageMs: number; checkout: string };
|
|
271
|
+
|
|
272
|
+
/** The live marker of another session in `target`'s checkout, or null (also on any error). */
|
|
273
|
+
export const liveConflict = (
|
|
274
|
+
target: string,
|
|
275
|
+
owner: LiveOwner,
|
|
276
|
+
deps: LiveDeps = {},
|
|
277
|
+
): LiveConflict | null => {
|
|
278
|
+
try {
|
|
279
|
+
const checkout = checkoutOf(target);
|
|
280
|
+
if (!checkout) return null;
|
|
281
|
+
const file = markerFile(checkout);
|
|
282
|
+
const marker = readMarker(file);
|
|
283
|
+
// Ids first: the owner's own marker never costs a process-table read.
|
|
284
|
+
if (!marker || sameLead(marker, owner) || !isLive(marker, deps)) return null;
|
|
285
|
+
if (sameOwner(marker, owner, deps.machine ?? os.hostname())) return null;
|
|
286
|
+
return { marker, file, ageMs: ageOf(marker, deps.now ?? new Date()), checkout };
|
|
287
|
+
} catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Record `owner` as the live session of `target`'s checkout (a fresh
|
|
294
|
+
* heartbeat), unless another live session owns it; one's own marker is
|
|
295
|
+
* rewritten at most every 30 s. Two sessions claiming a free checkout at
|
|
296
|
+
* the same instant race: each writes a temp file and renames it, the last
|
|
297
|
+
* rename wins, and the loser is refused from its next write on. Never throws.
|
|
298
|
+
*/
|
|
299
|
+
export const claimLiveSession = (target: string, owner: LiveOwner, deps: LiveDeps = {}): void => {
|
|
300
|
+
try {
|
|
301
|
+
const checkout = checkoutOf(target);
|
|
302
|
+
if (!checkout) return;
|
|
303
|
+
const file = markerFile(checkout);
|
|
304
|
+
const now = deps.now ?? new Date();
|
|
305
|
+
const machine = deps.machine ?? os.hostname();
|
|
306
|
+
const current = readMarker(file);
|
|
307
|
+
if (current) {
|
|
308
|
+
const age = ageOf(current, now);
|
|
309
|
+
// One's own fresh marker: nothing to read or write.
|
|
310
|
+
if (sameLead(current, owner) && age >= 0 && age < HEARTBEAT_EVERY_MS) return;
|
|
311
|
+
if (!sameLead(current, owner) && isLive(current, deps) && !sameOwner(current, owner, machine))
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const pid = owner.pid();
|
|
315
|
+
const marker: LiveMarker = {
|
|
316
|
+
version: 1,
|
|
317
|
+
session: owner.session,
|
|
318
|
+
leads: owner.leads,
|
|
319
|
+
host: owner.host,
|
|
320
|
+
pid,
|
|
321
|
+
pidStart: pid === null ? null : liveProbe.start(pid),
|
|
322
|
+
machine,
|
|
323
|
+
checkout,
|
|
324
|
+
heartbeatAt: now.toISOString(),
|
|
325
|
+
};
|
|
326
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
327
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
328
|
+
fs.writeFileSync(temp, JSON.stringify(marker));
|
|
329
|
+
fs.renameSync(temp, file);
|
|
330
|
+
} catch {
|
|
331
|
+
// Fail open: without a marker nobody is refused.
|
|
332
|
+
}
|
|
333
|
+
};
|
package/src/core/store-lock.ts
CHANGED
|
@@ -44,8 +44,16 @@ export const RECLAIM_GUARD_TTL_MS = 30_000;
|
|
|
44
44
|
* wait blocks the calling thread, so in-process hosts (OpenCode, MCP, Pi) keep
|
|
45
45
|
* the short default; the CLI raises it for its own process.
|
|
46
46
|
*/
|
|
47
|
-
|
|
47
|
+
export const IN_PROCESS_LOCK_TIMEOUT_MS = 250;
|
|
48
|
+
let defaultLockTimeoutMs = IN_PROCESS_LOCK_TIMEOUT_MS;
|
|
48
49
|
export const defaultLockTimeout = (): number => defaultLockTimeoutMs;
|
|
50
|
+
/**
|
|
51
|
+
* The wait for a lock that a CLI process may hold longer (the ledger's
|
|
52
|
+
* network-filesystem lock): `cliMs` once the process raised the default
|
|
53
|
+
* (the CLI does), else the in-process cap.
|
|
54
|
+
*/
|
|
55
|
+
export const hostAwareLockTimeout = (cliMs: number): number =>
|
|
56
|
+
defaultLockTimeoutMs > IN_PROCESS_LOCK_TIMEOUT_MS ? cliMs : IN_PROCESS_LOCK_TIMEOUT_MS;
|
|
49
57
|
export const setDefaultLockTimeout = (ms: number): void => {
|
|
50
58
|
defaultLockTimeoutMs = ms;
|
|
51
59
|
};
|
package/src/core/workspaces.ts
CHANGED
|
@@ -149,6 +149,16 @@ const workspaceConfigSchema = z
|
|
|
149
149
|
* authorship lives in the ledger only (`commit.recorded`).
|
|
150
150
|
*/
|
|
151
151
|
trailers: z.boolean().optional(),
|
|
152
|
+
/**
|
|
153
|
+
* Whether the live-session guard denies edits in a checkout another live
|
|
154
|
+
* agent session owns (default true; core/live-session.ts).
|
|
155
|
+
*/
|
|
156
|
+
liveSessionGuard: z.boolean().optional(),
|
|
157
|
+
/**
|
|
158
|
+
* Fanout settings. `tierModels` names the model per slice TIER, as one
|
|
159
|
+
* name or per host family; read leniently (workspaceTierModel).
|
|
160
|
+
*/
|
|
161
|
+
fanout: z.object({ tierModels: z.unknown().optional() }).passthrough().optional(),
|
|
152
162
|
/** Autonomy grants (D15): only ever set here, by the user. */
|
|
153
163
|
autonomy: z
|
|
154
164
|
.object(
|
|
@@ -695,35 +705,145 @@ export const resolveRuntimeReleaseTracks = (
|
|
|
695
705
|
: null;
|
|
696
706
|
};
|
|
697
707
|
|
|
708
|
+
type RuntimeEntry = Record<string, unknown> & { name: string };
|
|
709
|
+
|
|
698
710
|
/**
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
*
|
|
703
|
-
* unreadable workspaces.json, keeps the trailer. `from` names the entry the
|
|
704
|
-
* value came from (null for the default).
|
|
711
|
+
* The workspace selected for cwd and the entry a setting comes from: the
|
|
712
|
+
* selected workspace when `has(entry)`, else the nearest enclosing workspace
|
|
713
|
+
* that also matches cwd (a repo workspace nested in `work`). Read leniently:
|
|
714
|
+
* no workspace, or an unreadable workspaces.json, reads as null.
|
|
705
715
|
*/
|
|
706
|
-
|
|
716
|
+
const inheritedSetting = (
|
|
707
717
|
cwd: string,
|
|
708
|
-
dir
|
|
709
|
-
|
|
718
|
+
dir: string | undefined,
|
|
719
|
+
has: (entry: RuntimeEntry) => boolean,
|
|
720
|
+
): { workspace: string; source: RuntimeEntry | null } | null => {
|
|
710
721
|
let matched: ReturnType<typeof matchRuntimeWorkspace>;
|
|
711
722
|
try {
|
|
712
723
|
matched = matchRuntimeWorkspace(cwd, dir);
|
|
713
724
|
} catch {
|
|
714
|
-
return
|
|
725
|
+
return null;
|
|
715
726
|
}
|
|
716
|
-
if (!matched) return
|
|
727
|
+
if (!matched) return null;
|
|
717
728
|
const { selected, matches } = matched;
|
|
718
729
|
const enclosing = matches
|
|
719
730
|
.filter((entry) => entry !== selected)
|
|
720
731
|
.toSorted((a, b) => globSpecificity(b.glob) - globSpecificity(a.glob));
|
|
721
|
-
|
|
732
|
+
return { workspace: selected.name, source: [selected, ...enclosing].find(has) ?? null };
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Whether commits in cwd's workspace carry the `Workit-Session:` trailer.
|
|
737
|
+
* The selected workspace's own `trailers` wins; unset, it inherits from the
|
|
738
|
+
* nearest enclosing workspace that also matches cwd, and defaults to true.
|
|
739
|
+
* No workspace, or an unreadable workspaces.json, keeps the trailer. `from`
|
|
740
|
+
* names the entry the value came from (null for the default).
|
|
741
|
+
*/
|
|
742
|
+
export const workspaceTrailers = (
|
|
743
|
+
cwd: string,
|
|
744
|
+
dir?: string,
|
|
745
|
+
): { enabled: boolean; workspace: string | null; from: string | null } => {
|
|
746
|
+
const found = inheritedSetting(cwd, dir, (entry) => typeof entry.trailers === "boolean");
|
|
722
747
|
return {
|
|
723
|
-
enabled: source?.trailers !== false,
|
|
724
|
-
workspace:
|
|
725
|
-
from: source?.name ?? null,
|
|
748
|
+
enabled: found?.source?.trailers !== false,
|
|
749
|
+
workspace: found?.workspace ?? null,
|
|
750
|
+
from: found?.source?.name ?? null,
|
|
751
|
+
};
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Whether the live-session guard runs for cwd's workspace (`liveSessionGuard`,
|
|
756
|
+
* default true; inherited like `trailers`). An unreadable workspaces.json
|
|
757
|
+
* keeps it on: the guard itself fails open on any state error.
|
|
758
|
+
*/
|
|
759
|
+
export const workspaceLiveSessionGuard = (cwd: string, dir?: string): boolean =>
|
|
760
|
+
inheritedSetting(cwd, dir, (entry) => typeof entry.liveSessionGuard === "boolean")?.source
|
|
761
|
+
?.liveSessionGuard !== false;
|
|
762
|
+
|
|
763
|
+
const hostFamily = (host: string): string => host.replace(/_(?:cli|desktop)$/u, "");
|
|
764
|
+
|
|
765
|
+
const TIER_KEYS = new Set(["mundane", "standard", "hard"]);
|
|
766
|
+
const TIER_HOSTS = new Set([
|
|
767
|
+
"claude_code",
|
|
768
|
+
"opencode",
|
|
769
|
+
"codex",
|
|
770
|
+
"codex_cli",
|
|
771
|
+
"codex_desktop",
|
|
772
|
+
"cursor",
|
|
773
|
+
"pi",
|
|
774
|
+
"default",
|
|
775
|
+
]);
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* What `fanout.tierModels` in cwd's workspaces holds that Workit ignores: an
|
|
779
|
+
* unknown tier or host key, or a value that is not a model name. Read
|
|
780
|
+
* leniently (each one is skipped, never an error); `workit grant show`
|
|
781
|
+
* reports them.
|
|
782
|
+
*/
|
|
783
|
+
export const workspaceTierModelIssues = (cwd: string, dir?: string): string[] => {
|
|
784
|
+
let matched: ReturnType<typeof matchRuntimeWorkspace>;
|
|
785
|
+
try {
|
|
786
|
+
matched = matchRuntimeWorkspace(cwd, dir);
|
|
787
|
+
} catch {
|
|
788
|
+
return [];
|
|
789
|
+
}
|
|
790
|
+
const issues: string[] = [];
|
|
791
|
+
for (const entry of matched?.matches ?? []) {
|
|
792
|
+
const models = (entry.fanout as { tierModels?: unknown } | undefined)?.tierModels;
|
|
793
|
+
if (models === undefined) continue;
|
|
794
|
+
const where = `workspace ${entry.name} fanout.tierModels`;
|
|
795
|
+
if (!models || typeof models !== "object" || Array.isArray(models)) {
|
|
796
|
+
issues.push(`${where} is not an object; ignored`);
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
for (const [tier, value] of Object.entries(models)) {
|
|
800
|
+
if (!TIER_KEYS.has(tier)) issues.push(`${where}.${tier}: unknown tier; ignored`);
|
|
801
|
+
else if (typeof value === "string") {
|
|
802
|
+
if (!value.trim()) issues.push(`${where}.${tier}: empty model name; ignored`);
|
|
803
|
+
} else if (!value || typeof value !== "object" || Array.isArray(value))
|
|
804
|
+
issues.push(`${where}.${tier}: not a model name or a per-host object; ignored`);
|
|
805
|
+
else
|
|
806
|
+
for (const [host, model] of Object.entries(value)) {
|
|
807
|
+
if (!TIER_HOSTS.has(host)) issues.push(`${where}.${tier}.${host}: unknown host; ignored`);
|
|
808
|
+
else if (typeof model !== "string" || !model.trim())
|
|
809
|
+
issues.push(`${where}.${tier}.${host}: not a model name; ignored`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return issues;
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* The model `fanout.tierModels` names for a tier on a host, or null (the
|
|
818
|
+
* fanout skill's TIER guidance applies). A tier maps to one model name for
|
|
819
|
+
* every host, or to `{ <host>: <model> }` keyed by host family (claude_code,
|
|
820
|
+
* opencode, codex, cursor, pi) with an optional `default`. Inherited like
|
|
821
|
+
* `trailers`, per tier; anything malformed reads as unset.
|
|
822
|
+
*/
|
|
823
|
+
export const workspaceTierModel = (
|
|
824
|
+
cwd: string,
|
|
825
|
+
tier: string,
|
|
826
|
+
host: string | null,
|
|
827
|
+
dir?: string,
|
|
828
|
+
): { model: string; workspace: string } | null => {
|
|
829
|
+
const pick = (entry: RuntimeEntry): string | null => {
|
|
830
|
+
const fanout = entry.fanout as { tierModels?: unknown } | undefined;
|
|
831
|
+
const models = fanout?.tierModels;
|
|
832
|
+
if (!models || typeof models !== "object" || !Object.hasOwn(models, tier)) return null;
|
|
833
|
+
const value = (models as Record<string, unknown>)[tier];
|
|
834
|
+
if (typeof value === "string") return value.trim() || null;
|
|
835
|
+
if (!value || typeof value !== "object") return null;
|
|
836
|
+
const byHost = value as Record<string, unknown>;
|
|
837
|
+
const family = host ? hostFamily(host) : null;
|
|
838
|
+
const named =
|
|
839
|
+
(family && Object.hasOwn(byHost, family) ? byHost[family] : undefined) ??
|
|
840
|
+
(host && Object.hasOwn(byHost, host) ? byHost[host] : undefined) ??
|
|
841
|
+
(Object.hasOwn(byHost, "default") ? byHost.default : undefined);
|
|
842
|
+
return typeof named === "string" && named.trim() ? named.trim() : null;
|
|
726
843
|
};
|
|
844
|
+
const found = inheritedSetting(cwd, dir, (entry) => pick(entry) !== null);
|
|
845
|
+
const model = found?.source ? pick(found.source) : null;
|
|
846
|
+
return model && found?.source ? { model, workspace: found.source.name } : null;
|
|
727
847
|
};
|
|
728
848
|
|
|
729
849
|
const resolveRuntimeWorkspaceCandidate = (
|