faberun 0.3.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/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The controller's notification queue, one per run directory, plus the campaign
|
|
3
|
+
* handoff render that must never take a run down with it.
|
|
4
|
+
*
|
|
5
|
+
* Notifications are serialized per run because the outbox is a file: two
|
|
6
|
+
* concurrent appends interleave. `alreadyNotified` is the dedupe key check that
|
|
7
|
+
* keeps a resumed run from re-announcing what the previous controller already
|
|
8
|
+
* announced.
|
|
9
|
+
*/
|
|
10
|
+
import { NotifyQueue, appendInbox, renderNotification } from "../notify/index.mjs";
|
|
11
|
+
import { appendJsonl } from "../run/store.mjs";
|
|
12
|
+
import { compactCost, errorMessage } from "../util.mjs";
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { basename, dirname, join } from "node:path";
|
|
15
|
+
import { renderBrief } from "../campaign/brief.mjs";
|
|
16
|
+
import { renderHandoff } from "../campaign/index.mjs";
|
|
17
|
+
|
|
18
|
+
/** @typedef {import("../campaign/index.mjs").Campaign} Campaign */
|
|
19
|
+
/** @typedef {{path: string, campaign: Campaign}} CampaignRef */
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One notify queue per run, so the notify.jsonl receipt log stays scoped to the
|
|
23
|
+
* run that owns it across the whole controller lifetime. Delivery is lossy now,
|
|
24
|
+
* so the queue holds no retry state; the map survives because a run's queue is
|
|
25
|
+
* still the thing every enqueue goes through while the controller is alive.
|
|
26
|
+
* @type {Map<string, NotifyQueue>}
|
|
27
|
+
*/
|
|
28
|
+
export const notifyQueuesByRun = new Map();
|
|
29
|
+
/** @param {string} runDir @returns {NotifyQueue} */
|
|
30
|
+
export function notifyQueueFor(runDir) {
|
|
31
|
+
let queue = notifyQueuesByRun.get(runDir);
|
|
32
|
+
if (!queue) {
|
|
33
|
+
queue = new NotifyQueue({ runDir });
|
|
34
|
+
notifyQueuesByRun.set(runDir, queue);
|
|
35
|
+
}
|
|
36
|
+
return queue;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Whether `notify.jsonl` already carries a receipt for this exact logical
|
|
40
|
+
* event. A resumed controller starts a fresh in-memory notify queue, so
|
|
41
|
+
* without this durable check it would re-notify every node that was already
|
|
42
|
+
* terminal before the resume; the durable log is the only thing that
|
|
43
|
+
* survives the process boundary.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} runDir
|
|
46
|
+
* @param {string} dedupeKey
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
export function alreadyNotified(runDir, dedupeKey) {
|
|
50
|
+
const path = join(runDir, "notify.jsonl");
|
|
51
|
+
if (!existsSync(path)) return false;
|
|
52
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
53
|
+
if (!line.trim()) continue;
|
|
54
|
+
try {
|
|
55
|
+
if (JSON.parse(line).dedupeKey === dedupeKey) return true;
|
|
56
|
+
} catch {
|
|
57
|
+
// A torn trailing line was never a committed receipt.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The bounded, advisory-only spend thresholds a contract may declare at
|
|
65
|
+
* `contract.nodeAdvisory`:
|
|
66
|
+
*
|
|
67
|
+
* - `costUsd` — US dollars, `>= 0`, no default;
|
|
68
|
+
* - `durationSec` — seconds of wall clock from the node's `startedAt`, `>= 0`,
|
|
69
|
+
* no default.
|
|
70
|
+
*
|
|
71
|
+
* Either or both may be present. A crossing emits one advisory line per node
|
|
72
|
+
* per threshold and never stops the node: the no-ceiling decision is deliberate
|
|
73
|
+
* and measured against a recorded incident. Cost is only known once an
|
|
74
|
+
* invocation closes, so a cost advisory fires when a new usage record has
|
|
75
|
+
* landed; duration is observable live and fires from the tick that observes it.
|
|
76
|
+
* One-shotness is durable, not in-process: the dedupe key is recorded in the
|
|
77
|
+
* inbox and the run's `notify.jsonl`, so a controller restart reads the receipt
|
|
78
|
+
* and never re-fires.
|
|
79
|
+
*
|
|
80
|
+
* @typedef {{costUsd?: number, durationSec?: number}} NodeAdvisoryPolicy
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every advisory threshold this node has crossed, in a stable order. A node
|
|
85
|
+
* with no `startedAt` has never run, so duration is not judged; a node with no
|
|
86
|
+
* recorded cost is not judged against the cost threshold.
|
|
87
|
+
*
|
|
88
|
+
* @param {{id: string, startedAt?: string|null, costUsd?: number|null}} state
|
|
89
|
+
* @param {NodeAdvisoryPolicy|undefined} policy
|
|
90
|
+
* @param {number} [now] epoch milliseconds
|
|
91
|
+
* @returns {Array<{kind: "duration", threshold: number, value: number}|{kind: "cost", threshold: number, value: number}>}
|
|
92
|
+
*/
|
|
93
|
+
export function nodeAdvisoryCrossings(state, policy, now = Date.now()) {
|
|
94
|
+
/** @type {Array<{kind: "duration", threshold: number, value: number}|{kind: "cost", threshold: number, value: number}>} */
|
|
95
|
+
const crossings = [];
|
|
96
|
+
if (!policy) return crossings;
|
|
97
|
+
if (typeof policy.durationSec === "number" && typeof state.startedAt === "string") {
|
|
98
|
+
const started = Date.parse(state.startedAt);
|
|
99
|
+
if (Number.isFinite(started)) {
|
|
100
|
+
const elapsedSec = Math.max(0, (now - started) / 1000);
|
|
101
|
+
if (elapsedSec >= policy.durationSec) crossings.push({ kind: "duration", threshold: policy.durationSec, value: elapsedSec });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (typeof policy.costUsd === "number" && typeof state.costUsd === "number" && state.costUsd >= policy.costUsd) {
|
|
105
|
+
crossings.push({ kind: "cost", threshold: policy.costUsd, value: state.costUsd });
|
|
106
|
+
}
|
|
107
|
+
return crossings;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Emit one advisory through the campaign inbox and the run notify queue. The
|
|
112
|
+
* durable dedupe is shared by both: `alreadyNotified` reads `notify.jsonl` and
|
|
113
|
+
* `appendInbox` refuses a key already in `inbox.jsonl`, so a restart cannot
|
|
114
|
+
* re-fire and two writers cannot double-send.
|
|
115
|
+
*
|
|
116
|
+
* The key names the threshold, not just the kind: the contract declares one
|
|
117
|
+
* cost and one duration ceiling today, but a raised (or lowered) ceiling is a
|
|
118
|
+
* different one-shot, and a kind-only key would silence the new crossing
|
|
119
|
+
* forever. The threshold is part of the key so "one-shot per node per
|
|
120
|
+
* threshold" stays true if the configured value moves.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} runDir
|
|
123
|
+
* @param {string} campaignId
|
|
124
|
+
* @param {{id: string}} state
|
|
125
|
+
* @param {{kind: "duration", threshold: number, value: number}|{kind: "cost", threshold: number, value: number}} crossing
|
|
126
|
+
* @returns {Promise<boolean>} whether this call appended and delivered the line
|
|
127
|
+
*/
|
|
128
|
+
export async function emitNodeAdvisory(runDir, campaignId, state, crossing) {
|
|
129
|
+
const runId = basename(runDir);
|
|
130
|
+
const dedupeKey = `node.advisory:${runId}:${state.id}:${crossing.kind}:${crossing.threshold}`;
|
|
131
|
+
if (alreadyNotified(runDir, dedupeKey)) return false;
|
|
132
|
+
const summary = crossing.kind === "cost"
|
|
133
|
+
? `node ${state.id} crossed its advisory cost ${compactCost(crossing.threshold)} (recorded ${compactCost(crossing.value)}) · run ${runId}`
|
|
134
|
+
: `node ${state.id} crossed its advisory duration ${crossing.threshold}s (elapsed ${crossing.value.toFixed(1)}s) · run ${runId}`;
|
|
135
|
+
const appended = appendInbox(dirname(runDir), {
|
|
136
|
+
type: "advisory",
|
|
137
|
+
campaignId,
|
|
138
|
+
runId,
|
|
139
|
+
nodeId: state.id,
|
|
140
|
+
dedupeKey,
|
|
141
|
+
summary,
|
|
142
|
+
});
|
|
143
|
+
if (!appended.appended) return false;
|
|
144
|
+
await notifyQueueFor(runDir).enqueue(/** @type {any} */ ({
|
|
145
|
+
type: "advisory",
|
|
146
|
+
campaignId,
|
|
147
|
+
runId,
|
|
148
|
+
nodeId: state.id,
|
|
149
|
+
dedupeKey,
|
|
150
|
+
summary,
|
|
151
|
+
}));
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Check every node of the run against the contract's advisory thresholds and
|
|
157
|
+
* emit whatever it has newly crossed. Called once per controller tick; the
|
|
158
|
+
* durable dedupe makes the repeated check cheap and idempotent.
|
|
159
|
+
*
|
|
160
|
+
* @param {{campaignId: string, nodeAdvisory?: NodeAdvisoryPolicy, nodes: {id: string}[]}} contract
|
|
161
|
+
* @param {string} runDir
|
|
162
|
+
* @param {Map<string, {id: string, startedAt?: string|null, costUsd?: number|null}>} states
|
|
163
|
+
* @returns {Promise<number>} how many advisory lines were newly emitted
|
|
164
|
+
*/
|
|
165
|
+
export async function emitNodeAdvisories(contract, runDir, states) {
|
|
166
|
+
const policy = contract.nodeAdvisory;
|
|
167
|
+
if (!policy || (policy.costUsd === undefined && policy.durationSec === undefined)) return 0;
|
|
168
|
+
let emitted = 0;
|
|
169
|
+
for (const node of contract.nodes) {
|
|
170
|
+
const state = states.get(node.id);
|
|
171
|
+
if (!state) continue;
|
|
172
|
+
for (const crossing of nodeAdvisoryCrossings(state, policy)) {
|
|
173
|
+
if (await emitNodeAdvisory(runDir, contract.campaignId, state, crossing)) emitted += 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return emitted;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The escalating re-nag schedule, in milliseconds from the moment the run was
|
|
181
|
+
* first parked: 10 minutes, 1 hour, 4 hours, then every 4 hours after that.
|
|
182
|
+
* The schedule is the whole point of a slot-keyed dedupe: an attention event
|
|
183
|
+
* is not announced once and forgotten, it is announced once per slot until the
|
|
184
|
+
* run is resumed or the campaign closes.
|
|
185
|
+
*/
|
|
186
|
+
export const ATTENTION_SCHEDULE_MS = [10 * 60_000, 60 * 60_000, 4 * 60 * 60_000];
|
|
187
|
+
export const ATTENTION_REPEAT_MS = 4 * 60 * 60_000;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Which schedule slot an elapsed time falls in, or `-1` before the first one.
|
|
191
|
+
* Slot 0 is 10 minutes, slot 1 is 1 hour, slot 2 is 4 hours, slot 3 is 8 hours,
|
|
192
|
+
* and so on every 4 hours thereafter.
|
|
193
|
+
*
|
|
194
|
+
* @param {number} elapsedMs milliseconds since the run was first parked
|
|
195
|
+
* @returns {number}
|
|
196
|
+
*/
|
|
197
|
+
export function attentionScheduleSlot(elapsedMs) {
|
|
198
|
+
if (!Number.isFinite(elapsedMs) || elapsedMs < ATTENTION_SCHEDULE_MS[0]) return -1;
|
|
199
|
+
if (elapsedMs < ATTENTION_SCHEDULE_MS[1]) return 0;
|
|
200
|
+
if (elapsedMs < ATTENTION_SCHEDULE_MS[2]) return 1;
|
|
201
|
+
return 2 + Math.floor((elapsedMs - ATTENTION_SCHEDULE_MS[2]) / ATTENTION_REPEAT_MS);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The durable dedupe key for one schedule slot of one parked interval. The
|
|
206
|
+
* anchor is the instant the run was first parked, so a resume that clears the
|
|
207
|
+
* attention and a later re-park start a fresh key rather than colliding with
|
|
208
|
+
* the previous interval's receipts.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} runId
|
|
211
|
+
* @param {string} anchor ISO instant the run was first parked
|
|
212
|
+
* @param {number} slot
|
|
213
|
+
* @returns {string}
|
|
214
|
+
*/
|
|
215
|
+
export function attentionDedupeKey(runId, anchor, slot) {
|
|
216
|
+
return `run.attention:${runId}:${anchor}:${slot}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Emit the run-level attention event for the schedule slot `now` falls in, or
|
|
221
|
+
* nothing when the schedule has not reached its first slot or this slot was
|
|
222
|
+
* already announced. A receipt is the durable record, so a restarted
|
|
223
|
+
* supervisor reads the same slot as already sent.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} runDir
|
|
226
|
+
* @param {{anchor: string, code?: string|null, campaignId?: string|null, now?: number}} options
|
|
227
|
+
* @returns {Promise<number|null>} the emitted slot, or null when none was due
|
|
228
|
+
*/
|
|
229
|
+
export async function emitScheduledAttention(runDir, options) {
|
|
230
|
+
const now = options.now ?? Date.now();
|
|
231
|
+
const anchorMs = Date.parse(options.anchor);
|
|
232
|
+
const slot = attentionScheduleSlot(now - anchorMs);
|
|
233
|
+
if (slot < 0) return null;
|
|
234
|
+
const runId = basename(runDir);
|
|
235
|
+
const dedupeKey = attentionDedupeKey(runId, options.anchor, slot);
|
|
236
|
+
if (alreadyNotified(runDir, dedupeKey)) return null;
|
|
237
|
+
const summary = renderNotification({
|
|
238
|
+
type: "attention",
|
|
239
|
+
runId,
|
|
240
|
+
errorCode: options.code ?? null,
|
|
241
|
+
});
|
|
242
|
+
// The run-level attention also lands in the campaign-level inbox, which is
|
|
243
|
+
// the append-only record the managed signal block summarises.
|
|
244
|
+
appendInbox(dirname(runDir), {
|
|
245
|
+
type: "attention",
|
|
246
|
+
campaignId: options.campaignId ?? null,
|
|
247
|
+
runId,
|
|
248
|
+
errorCode: options.code ?? null,
|
|
249
|
+
dedupeKey,
|
|
250
|
+
summary,
|
|
251
|
+
});
|
|
252
|
+
await notifyQueueFor(runDir).enqueue({
|
|
253
|
+
type: "attention",
|
|
254
|
+
campaignId: options.campaignId ?? null,
|
|
255
|
+
runId,
|
|
256
|
+
errorCode: options.code ?? null,
|
|
257
|
+
dedupeKey,
|
|
258
|
+
});
|
|
259
|
+
return slot;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Campaign-level notifications have no run directory to queue into, so they
|
|
264
|
+
* are recorded in `<runs-dir>/inbox.jsonl` and delivered through a queue whose
|
|
265
|
+
* receipt log is `<campaign-dir>/notify.jsonl`. That is the defined home for a
|
|
266
|
+
* campaign watcher's lines, which can be emitted with no run active.
|
|
267
|
+
*
|
|
268
|
+
* `appendInbox` is the durable dedupe: a key already recorded is neither
|
|
269
|
+
* re-appended nor re-delivered, so a restarted watcher does not double-send.
|
|
270
|
+
*
|
|
271
|
+
* @param {{runsDir: string, campaignPath: string, campaignId?: string|null, runId?: string|null, nodeId?: string|null, status?: string|null, errorCode?: string|null, dedupeKey: string, summary: string, type?: string}} args
|
|
272
|
+
* @returns {Promise<boolean>} whether a new notification was delivered
|
|
273
|
+
*/
|
|
274
|
+
export async function enqueueCampaignNotification(args) {
|
|
275
|
+
const appended = appendInbox(args.runsDir, {
|
|
276
|
+
type: "attention",
|
|
277
|
+
campaignId: args.campaignId ?? null,
|
|
278
|
+
runId: args.runId ?? null,
|
|
279
|
+
nodeId: args.nodeId ?? null,
|
|
280
|
+
status: args.status ?? null,
|
|
281
|
+
errorCode: args.errorCode ?? null,
|
|
282
|
+
dedupeKey: args.dedupeKey,
|
|
283
|
+
summary: args.summary,
|
|
284
|
+
});
|
|
285
|
+
if (!appended.appended) return false;
|
|
286
|
+
await notifyQueueFor(args.campaignPath).enqueue({
|
|
287
|
+
type: "attention",
|
|
288
|
+
campaignId: args.campaignId ?? null,
|
|
289
|
+
runId: args.runId ?? null,
|
|
290
|
+
nodeId: args.nodeId ?? null,
|
|
291
|
+
status: args.status ?? null,
|
|
292
|
+
errorCode: args.errorCode ?? null,
|
|
293
|
+
dedupeKey: args.dedupeKey,
|
|
294
|
+
summary: args.summary,
|
|
295
|
+
});
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* @param {CampaignRef} campaign
|
|
300
|
+
* @param {string} runsDir
|
|
301
|
+
* @param {string} runDir
|
|
302
|
+
* @returns {boolean}
|
|
303
|
+
*/
|
|
304
|
+
export function renderCampaignHandoffSafely(campaign, runsDir, runDir) {
|
|
305
|
+
try {
|
|
306
|
+
renderHandoff(campaign.path, runsDir);
|
|
307
|
+
// The brief rides the same seam so it exists even when the seat dies
|
|
308
|
+
// without warning; the journal, not the seat, is what makes it rebuildable.
|
|
309
|
+
renderBrief(campaign.path, runsDir);
|
|
310
|
+
return true;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
/** @type {Record<string, unknown>} */
|
|
313
|
+
const diagnostic = {
|
|
314
|
+
type: "campaign.handoff-failed",
|
|
315
|
+
at: new Date().toISOString(),
|
|
316
|
+
campaignId: campaign.campaign.id,
|
|
317
|
+
error: errorMessage(error),
|
|
318
|
+
};
|
|
319
|
+
try {
|
|
320
|
+
appendJsonl(join(runDir, "events.jsonl"), diagnostic);
|
|
321
|
+
} catch {
|
|
322
|
+
// A failed diagnostic must not abort the controller either.
|
|
323
|
+
}
|
|
324
|
+
process.stderr.write(`[warn] campaign handoff render failed: ${errorMessage(error)}\n`);
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a recorded invocation still names the process the controller started.
|
|
3
|
+
*
|
|
4
|
+
* This is its own module because the two callers hold different evidence:
|
|
5
|
+
* `process.mjs` owns a live `ChildProcess` and can prove a pid has not been
|
|
6
|
+
* reaped, while recovery and cancel hold only a persisted record and must rely
|
|
7
|
+
* on the start token. Keeping the proof here lets both ask the same question
|
|
8
|
+
* without either importing the other's spawn, signal, or journal machinery.
|
|
9
|
+
*/
|
|
10
|
+
import { errorCode } from "../util.mjs";
|
|
11
|
+
import { processStartToken } from "../run/lock.mjs";
|
|
12
|
+
|
|
13
|
+
/** @typedef {import("node:child_process").ChildProcess} ChildProcess */
|
|
14
|
+
/** @typedef {{pid: number|null, processGroupId?: number|null, processStartToken?: string|null}} InvocationProbe */
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A process group answers a signal-0 probe. EPERM means the group exists but is
|
|
18
|
+
* not this user's, which is no more "ours" than a missing group.
|
|
19
|
+
*
|
|
20
|
+
* @param {number|null} processGroupId
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
23
|
+
export function processGroupAlive(processGroupId) {
|
|
24
|
+
if (process.platform === "win32" || typeof processGroupId !== "number" || !Number.isInteger(processGroupId) || processGroupId <= 0) return false;
|
|
25
|
+
try {
|
|
26
|
+
process.kill(-processGroupId, 0);
|
|
27
|
+
return true;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return errorCode(error) === "EPERM";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A recorded token is evidence only when it still names the live pid. An absent
|
|
35
|
+
* token proves nothing on its own; callers that need proof use
|
|
36
|
+
* {@link invocationOwned} instead.
|
|
37
|
+
*
|
|
38
|
+
* @param {InvocationProbe} invocation
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
export function processStartTokenMatches(invocation) {
|
|
42
|
+
if (!invocation.processStartToken) return true;
|
|
43
|
+
const current = processStartToken(invocation.pid);
|
|
44
|
+
return current === invocation.processStartToken;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The controller's proof of ownership. The leader pid must answer a signal-0
|
|
49
|
+
* probe, and either a live child handle proves the pid cannot have been reaped,
|
|
50
|
+
* or a recorded start token still names that pid now. EPERM is not alive-and-ours
|
|
51
|
+
* -- a pid this user cannot signal is not the child this controller spawned --
|
|
52
|
+
* and a null token with no handle is unverifiable, so it is never owned.
|
|
53
|
+
*
|
|
54
|
+
* @param {InvocationProbe} invocation
|
|
55
|
+
* @param {{child?: ChildProcess|null}} [options]
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
export function invocationOwned(invocation, options = {}) {
|
|
59
|
+
if (!invocation?.pid || !Number.isInteger(invocation.pid)) return false;
|
|
60
|
+
try {
|
|
61
|
+
process.kill(invocation.pid, 0);
|
|
62
|
+
} catch {
|
|
63
|
+
// ESRCH: the pid is gone. EPERM: it is alive but not ours; either way this
|
|
64
|
+
// controller cannot prove it started that process.
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const child = options.child;
|
|
68
|
+
if (child && child.exitCode === null && child.signalCode === null) return true;
|
|
69
|
+
const token = invocation.processStartToken;
|
|
70
|
+
if (!token) return false;
|
|
71
|
+
return processStartToken(invocation.pid) === token;
|
|
72
|
+
}
|