@brainervirus/workit-core 8.17.0 → 8.17.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.17.0",
3
+ "version": "8.17.1",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -0,0 +1,228 @@
1
+ // The hook's own deadline (issue #309). A host kills a hook past its timeout
2
+ // and discards its output; under machine load that silently drops the Workit
3
+ // context and gates. Past this deadline, below every host timeout, the hook
4
+ // answers a minimal valid result naming the skip and records it for doctor.
5
+ import { appendFileSync, mkdirSync } from "node:fs";
6
+ import path from "node:path";
7
+ import { resolveStateDir } from "../core/logger";
8
+ import { dispatchHook } from "./handle";
9
+ import type { HookDecision, HookEventKind, HostAdapter, RenderedHook } from "./protocol";
10
+
11
+ export const HOOK_SKIP_TEXT = "workit context skipped: host under load";
12
+
13
+ /** For a host without its own row; Claude Code's prompt/tool/subagent value. */
14
+ export const HOOK_DEADLINE_MS = 8_000;
15
+
16
+ /**
17
+ * Per host, by native event ("" for the rest): a margin below the host's own
18
+ * timeout, left for a dispatch that started in time. Claude Code registers
19
+ * 15 s (20 s for SessionStart and Stop); Codex defaults to 600 s and Workit
20
+ * registers 20 s for Stop; Cursor defaults to 60 s.
21
+ */
22
+ export const HOST_DEADLINES: Readonly<Record<string, Readonly<Record<string, number>>>> = {
23
+ claude_code: { "": 8_000, SessionStart: 16_000, Stop: 16_000 },
24
+ codex_cli: { "": 45_000, Stop: 16_000 },
25
+ codex_desktop: { "": 45_000, Stop: 16_000 },
26
+ cursor: { "": 45_000 },
27
+ };
28
+
29
+ /** One JSON HookSkip per line; `workit doctor` (hook_deadline) reads it. */
30
+ export const HOOK_SKIPS_FILE = "hook-skips.jsonl";
31
+
32
+ /** `skip`: the hook answered the minimal result; `late`: it answered in full, past the deadline. */
33
+ export type HookSkip = {
34
+ at: string;
35
+ host: string;
36
+ event: HookEventKind | null;
37
+ outcome: "skip" | "late";
38
+ stage: "input" | "dispatch" | "done";
39
+ elapsedMs: number;
40
+ };
41
+
42
+ export type HookDeadline = {
43
+ /** Milliseconds from `startedAt`; by default WORKIT_HOOK_DEADLINE_MS, else HOST_DEADLINES. */
44
+ deadlineMs?: number;
45
+ /**
46
+ * Epoch ms the budget counts from; the call by default. A hook-process
47
+ * entry passes PROCESS_START so its start-up and module load count too.
48
+ */
49
+ startedAt?: number;
50
+ now?: () => number;
51
+ /** The handler step; dispatchHook unless a host handles the event itself. */
52
+ dispatch?: (
53
+ adapter: HostAdapter,
54
+ raw: unknown,
55
+ env: NodeJS.ProcessEnv,
56
+ ) => (RenderedHook & { error: string | null }) | Promise<RenderedHook & { error: string | null }>;
57
+ record?: (skip: HookSkip) => void;
58
+ };
59
+
60
+ export type DeadlineResult = RenderedHook & { error: string | null; skipped: boolean };
61
+
62
+ const override = (env: NodeJS.ProcessEnv): number | null => {
63
+ const value = Number(env.WORKIT_HOOK_DEADLINE_MS);
64
+ return Number.isFinite(value) && value > 0 ? value : null;
65
+ };
66
+
67
+ /** The deadline for one native event; `native` undefined gives the host's shortest. */
68
+ export const hookDeadlineMs = (env: NodeJS.ProcessEnv, host = "", native?: string): number => {
69
+ const row = HOST_DEADLINES[host];
70
+ return (
71
+ override(env) ??
72
+ (row
73
+ ? native === undefined
74
+ ? Math.min(...Object.values(row))
75
+ : (row[native] ?? row[""])
76
+ : HOOK_DEADLINE_MS)
77
+ );
78
+ };
79
+
80
+ const skipsFile = (stateDir: string) => path.join(stateDir, HOOK_SKIPS_FILE);
81
+
82
+ /** Best effort: a read-only state dir never changes the hook's answer. */
83
+ export const recordHookSkip = (skip: HookSkip, stateDir: string = resolveStateDir()): void => {
84
+ try {
85
+ mkdirSync(stateDir, { recursive: true });
86
+ appendFileSync(skipsFile(stateDir), `${JSON.stringify(skip)}\n`);
87
+ } catch {
88
+ // The skip still reached the host; only the doctor count is lost.
89
+ }
90
+ };
91
+
92
+ const QUIET: ReadonlySet<HookEventKind | null> = new Set([
93
+ "stop",
94
+ "subagent.stop",
95
+ "shell.post",
96
+ "compact.pre",
97
+ "prompt.submit",
98
+ null,
99
+ ]);
100
+
101
+ /** The minimal answer: the skip named where the event has a model channel, else no decision. */
102
+ const skipDecision = (event: HookEventKind | null): HookDecision =>
103
+ QUIET.has(event) ? { kind: "none" } : { kind: "context", text: HOOK_SKIP_TEXT };
104
+
105
+ const TIMED_OUT = Symbol("timed out");
106
+
107
+ /** Resolves to `work`'s value, or TIMED_OUT when `ms` passes first. */
108
+ export const within = async <T>(work: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT> => {
109
+ let timer: ReturnType<typeof setTimeout> | undefined;
110
+ const expired = new Promise<typeof TIMED_OUT>((resolve) => {
111
+ timer = setTimeout(() => resolve(TIMED_OUT), Math.max(0, ms));
112
+ timer.unref?.();
113
+ });
114
+ try {
115
+ return await Promise.race([work, expired]);
116
+ } finally {
117
+ clearTimeout(timer);
118
+ }
119
+ };
120
+
121
+ export const isTimedOut = (value: unknown): value is typeof TIMED_OUT => value === TIMED_OUT;
122
+
123
+ type Budget = Required<Pick<HookDeadline, "now" | "record">> & {
124
+ startedAt: number;
125
+ /** The host's shortest until the payload names its event. */
126
+ deadlineMs: number;
127
+ /** The host whose HOST_DEADLINES row refines it per event; null when the caller fixed it. */
128
+ host: string | null;
129
+ };
130
+
131
+ /** Epoch ms this process started. */
132
+ export const PROCESS_START = performance.timeOrigin;
133
+
134
+ export const hookBudget = (
135
+ env: NodeJS.ProcessEnv,
136
+ options: HookDeadline = {},
137
+ host = "",
138
+ ): Budget => {
139
+ const now = options.now ?? Date.now;
140
+ return {
141
+ now,
142
+ record: options.record ?? ((skip) => recordHookSkip(skip)),
143
+ startedAt: options.startedAt ?? now(),
144
+ deadlineMs: options.deadlineMs ?? hookDeadlineMs(env, host),
145
+ host: options.deadlineMs === undefined ? host : null,
146
+ };
147
+ };
148
+
149
+ /** Reads the whole payload, or null when the deadline passes first. */
150
+ export const readWithin = async (
151
+ stdin: AsyncIterable<unknown> | Iterable<unknown>,
152
+ budget: Budget,
153
+ ): Promise<string | null> => {
154
+ const read = (async () => {
155
+ let text = "";
156
+ for await (const chunk of stdin) text += String(chunk);
157
+ return text;
158
+ })();
159
+ const text = await within(read, budget.startedAt + budget.deadlineMs - budget.now());
160
+ return isTimedOut(text) ? null : text;
161
+ };
162
+
163
+ /** Records one outcome; returns the parsed native event name for rendering. */
164
+ const note = (
165
+ adapter: HostAdapter,
166
+ raw: unknown,
167
+ env: NodeJS.ProcessEnv,
168
+ budget: Budget,
169
+ outcome: HookSkip["outcome"],
170
+ stage: HookSkip["stage"],
171
+ ): { native: string | null; event: HookEventKind | null } => {
172
+ const parsed = raw === undefined ? null : adapter.parse(raw, env);
173
+ const event = parsed ? (parsed.ok ? parsed.input.event.kind : parsed.event) : null;
174
+ budget.record({
175
+ at: new Date(budget.now()).toISOString(),
176
+ host: adapter.descriptor.host,
177
+ event,
178
+ outcome,
179
+ stage,
180
+ elapsedMs: Math.round(budget.now() - budget.startedAt),
181
+ });
182
+ return { native: parsed?.native ?? null, event };
183
+ };
184
+
185
+ /** The minimal result for a skipped hook, rendered in the host's own shape. */
186
+ export const skippedHook = (
187
+ adapter: HostAdapter,
188
+ raw: unknown,
189
+ env: NodeJS.ProcessEnv,
190
+ budget: Budget,
191
+ stage: HookSkip["stage"],
192
+ ): DeadlineResult => {
193
+ const { native, event } = note(adapter, raw, env, budget, "skip", stage);
194
+ return {
195
+ ...adapter.render(skipDecision(event), native),
196
+ error: `${HOOK_SKIP_TEXT} (past the ${budget.deadlineMs} ms hook deadline)`,
197
+ skipped: true,
198
+ };
199
+ };
200
+
201
+ /**
202
+ * dispatchHook under the deadline: a hook already past it answers the
203
+ * minimal result without running the handler, and a handler that overruns
204
+ * it is abandoned. A synchronous handler cannot be interrupted once started;
205
+ * one that finishes late answers in full and is recorded as late.
206
+ */
207
+ export const dispatchWithin = async (
208
+ adapter: HostAdapter,
209
+ raw: unknown,
210
+ env: NodeJS.ProcessEnv,
211
+ budget: Budget,
212
+ dispatch: NonNullable<HookDeadline["dispatch"]> = dispatchHook,
213
+ ): Promise<DeadlineResult> => {
214
+ const native = (raw as { hook_event_name?: unknown } | null)?.hook_event_name;
215
+ if (budget.host !== null && typeof native === "string")
216
+ budget.deadlineMs = hookDeadlineMs(env, budget.host, native);
217
+ const remaining = () => budget.startedAt + budget.deadlineMs - budget.now();
218
+ const result =
219
+ remaining() > 0
220
+ ? await within(
221
+ Promise.resolve().then(() => dispatch(adapter, raw, env)),
222
+ remaining(),
223
+ )
224
+ : TIMED_OUT;
225
+ if (isTimedOut(result)) return skippedHook(adapter, raw, env, budget, "dispatch");
226
+ if (remaining() < 0) note(adapter, raw, env, budget, "late", "done");
227
+ return { ...result, skipped: false };
228
+ };
package/src/hooks/run.ts CHANGED
@@ -1,5 +1,13 @@
1
- // Hook-process entry: stdin JSON → parse → handle → render → stdout.
2
- import { dispatchHook } from "./handle";
1
+ // Hook-process entry: stdin JSON → parse → handle → render → stdout, under
2
+ // the hook's own deadline (deadline.ts).
3
+ import {
4
+ dispatchWithin,
5
+ hookBudget,
6
+ PROCESS_START,
7
+ readWithin,
8
+ skippedHook,
9
+ type HookDeadline,
10
+ } from "./deadline";
3
11
  import { claudeCodeAdapter } from "./hosts/claude-code";
4
12
  import { codexAdapter } from "./hosts/codex";
5
13
  import { cursorAdapter } from "./hosts/cursor";
@@ -17,25 +25,31 @@ export const HOOK_ADAPTERS: Record<ProcessHost, HostAdapter> = {
17
25
  type Sink = { write(chunk: string): unknown };
18
26
 
19
27
  /** Runs one hook invocation and returns the process exit code. A broken
20
- * payload never throws: the host's fail policy decides what is written. */
28
+ * payload never throws: the host's fail policy decides what is written. Past
29
+ * the deadline the host gets a minimal result naming the skip. */
21
30
  export async function runHookProcess(
22
31
  host: ProcessHost | HostAdapter,
23
32
  stdin: AsyncIterable<unknown> | Iterable<unknown>,
24
33
  stdout: Sink,
25
34
  stderr: Sink = process.stderr,
26
35
  env: NodeJS.ProcessEnv = process.env,
36
+ deadline: HookDeadline = {},
27
37
  ): Promise<number> {
28
38
  const adapter = typeof host === "string" ? HOOK_ADAPTERS[host] : host;
29
- let text = "";
30
- for await (const chunk of stdin) text += String(chunk);
39
+ const budget = hookBudget(env, deadline, adapter.descriptor.host);
40
+ const text = await readWithin(stdin, budget);
31
41
  let raw: unknown;
32
42
  try {
33
- raw = JSON.parse(text || "{}");
43
+ raw = text === null ? undefined : JSON.parse(text || "{}");
34
44
  } catch {
35
45
  raw = undefined;
36
46
  }
37
- const result = dispatchHook(adapter, raw, env);
38
- if (raw === undefined || result.error)
47
+ const result =
48
+ text === null
49
+ ? skippedHook(adapter, undefined, env, budget, "input")
50
+ : await dispatchWithin(adapter, raw, env, budget, deadline.dispatch);
51
+ if (result.skipped) stderr.write(`[workit] ${result.error}\n`);
52
+ else if (raw === undefined || result.error)
39
53
  stderr.write(
40
54
  `[workit] hook input rejected: ${raw === undefined ? "invalid JSON hook input" : result.error}\n`,
41
55
  );
@@ -48,5 +62,16 @@ if (import.meta.main) {
48
62
  if (!Object.hasOwn(HOOK_ADAPTERS, host)) {
49
63
  process.stderr.write(`usage: run.ts <${Object.keys(HOOK_ADAPTERS).join("|")}>\n`);
50
64
  process.exitCode = 64;
51
- } else process.exitCode = await runHookProcess(host, process.stdin, process.stdout);
65
+ } else {
66
+ process.exitCode = await runHookProcess(
67
+ host,
68
+ process.stdin,
69
+ process.stdout,
70
+ process.stderr,
71
+ process.env,
72
+ { startedAt: PROCESS_START },
73
+ );
74
+ // A payload that never ended must not keep the process alive past its answer.
75
+ process.stdin.destroy();
76
+ }
52
77
  }