@alphacoder-v0/pi-loops 0.17.3
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/CHANGELOG.md +1989 -0
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/README.zh-CN.md +606 -0
- package/docs/cli.md +252 -0
- package/docs/configuration.md +110 -0
- package/docs/design.md +155 -0
- package/docs/goal.md +59 -0
- package/docs/hooks.md +110 -0
- package/docs/loops.md +315 -0
- package/docs/mcp.md +90 -0
- package/docs/screenshot.png +0 -0
- package/docs/session-archive.md +64 -0
- package/docs/triggers.md +140 -0
- package/docs/troubleshooting.md +111 -0
- package/docs/web-ui-parity.md +305 -0
- package/examples/README.md +18 -0
- package/examples/mcp-notify-server.mjs +35 -0
- package/examples/mcp.toml +8 -0
- package/package.json +71 -0
- package/skills/pi-loops/SKILL.md +50 -0
- package/src/archive.ts +476 -0
- package/src/args.ts +217 -0
- package/src/cli-entry.mjs +23 -0
- package/src/cli.ts +610 -0
- package/src/config.ts +103 -0
- package/src/danger.ts +128 -0
- package/src/goal.ts +178 -0
- package/src/hooks.ts +436 -0
- package/src/host-control-channel.ts +243 -0
- package/src/host-control.ts +203 -0
- package/src/host-entry.mjs +14 -0
- package/src/host-runtime.ts +222 -0
- package/src/host.ts +329 -0
- package/src/inbox.ts +232 -0
- package/src/job-edit.ts +113 -0
- package/src/job-health.ts +29 -0
- package/src/lock.ts +193 -0
- package/src/log.ts +108 -0
- package/src/mcp-pool.ts +141 -0
- package/src/mcp.ts +885 -0
- package/src/paths.ts +33 -0
- package/src/pi-loops.ts +2292 -0
- package/src/pi-resolver.mjs +49 -0
- package/src/presence.ts +143 -0
- package/src/protocol.ts +198 -0
- package/src/redact.ts +61 -0
- package/src/register-pi.mjs +3 -0
- package/src/runner.ts +149 -0
- package/src/schedule.ts +431 -0
- package/src/scheduler.ts +981 -0
- package/src/sdk-runner.ts +536 -0
- package/src/share.ts +121 -0
- package/src/slots.ts +76 -0
- package/src/snapshot.ts +40 -0
- package/src/store.ts +599 -0
- package/src/subagent-guard.ts +47 -0
- package/src/thinking.ts +30 -0
- package/src/toml.ts +244 -0
- package/src/tools.ts +455 -0
- package/src/transcript.ts +84 -0
- package/src/trigger-runtime.ts +711 -0
- package/src/triggers.ts +558 -0
- package/src/trust.ts +47 -0
- package/src/ts-entry.mjs +67 -0
- package/src/ui-prefs.ts +84 -0
- package/src/version.ts +14 -0
- package/src/web.mjs +4484 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trigger runtime: takes Trigger envelopes from sources (the periodic dynamic
|
|
3
|
+
* checker, MCP pushes), dedups them, runs the dynamic-rule sub-agent (or delivers a
|
|
4
|
+
* notification straight into the chat), marks fire-once rules, promotes results
|
|
5
|
+
* when asked, and keeps audit + running state for /triggers.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { realpathish } from "./paths.ts";
|
|
12
|
+
import { type PresenceEntry, type PresenceSelf, chooseRuleOwner, isSelf, withinProject } from "./presence.ts";
|
|
13
|
+
import { capRedacted, previewRedacted } from "./redact.ts";
|
|
14
|
+
import { type RunnerResult, type SubagentRunner, failedRun } from "./runner.ts";
|
|
15
|
+
import { type SubagentSlot, SubagentSlots } from "./slots.ts";
|
|
16
|
+
import type { JobStore } from "./store.ts";
|
|
17
|
+
import { backoffWaitMs, stamp } from "./schedule.ts";
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_TRIGGER_POLL_INTERVAL_SECS,
|
|
20
|
+
DedupWindow,
|
|
21
|
+
NO_MATCH_SENTINEL,
|
|
22
|
+
PollLedger,
|
|
23
|
+
SUMMARY_CAP_CHARS,
|
|
24
|
+
type DynamicTriggerRule,
|
|
25
|
+
type Trigger,
|
|
26
|
+
TriggerStore,
|
|
27
|
+
buildPeriodicCheckTrigger,
|
|
28
|
+
extractDynamicRuleIds,
|
|
29
|
+
renderDynamicTriggerPrompt,
|
|
30
|
+
newTraceId } from "./triggers.ts";
|
|
31
|
+
|
|
32
|
+
export type TriggerDelivery = "sub_agent" | "inject_summary" | "inject_and_run";
|
|
33
|
+
|
|
34
|
+
export interface RunningTrigger {
|
|
35
|
+
traceId: string;
|
|
36
|
+
sourceLabel: string;
|
|
37
|
+
eventLabel: string;
|
|
38
|
+
startedAt: string;
|
|
39
|
+
promptPreview: string;
|
|
40
|
+
cwd: string;
|
|
41
|
+
ctrl: AbortController;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TriggerOutcome {
|
|
45
|
+
trigger: Trigger;
|
|
46
|
+
delivery: TriggerDelivery;
|
|
47
|
+
ok: boolean;
|
|
48
|
+
matchedRules: DynamicTriggerRule[];
|
|
49
|
+
summary: string;
|
|
50
|
+
error?: string;
|
|
51
|
+
durationMs: number;
|
|
52
|
+
cost: number;
|
|
53
|
+
promoted: boolean;
|
|
54
|
+
sessionFile?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Where a promotion / injection actually went: the chat of this process, or the inbox (wrong project here). */
|
|
58
|
+
export type PromoteTarget = "chat" | "inbox";
|
|
59
|
+
|
|
60
|
+
export interface TriggerRuntimeHooks {
|
|
61
|
+
/** Insert text into the parent chat context (no model turn). Already carries the `[Trigger …]` prefix. Returns where it went. */
|
|
62
|
+
onPromote?: (content: string, trigger: Trigger) => PromoteTarget | Promise<PromoteTarget>;
|
|
63
|
+
/** Inject `prompt` into the parent chat and run one model turn. Returns where it went. */
|
|
64
|
+
onInjectAndRun?: (prompt: string, trigger: Trigger) => PromoteTarget | Promise<PromoteTarget>;
|
|
65
|
+
onStarted?: (running: RunningTrigger) => void;
|
|
66
|
+
onFinished?: (outcome: TriggerOutcome) => void;
|
|
67
|
+
log?: (message: string) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface TriggerRuntimeOptions {
|
|
71
|
+
store: TriggerStore;
|
|
72
|
+
jobStore: JobStore;
|
|
73
|
+
getSession: () => { sessionId?: string; cwd: string; model?: string; thinking?: string; trusted?: boolean };
|
|
74
|
+
hooks?: TriggerRuntimeHooks;
|
|
75
|
+
pollIntervalSecs?: number;
|
|
76
|
+
/** Runs check/action sub-agents (in-process through pi's SDK in production; tests inject a fake). */
|
|
77
|
+
runner: SubagentRunner;
|
|
78
|
+
now?: () => number;
|
|
79
|
+
/** Shared dedup file (`<dir>/dedup.json`); omit for an in-memory window. */
|
|
80
|
+
dedupFile?: string;
|
|
81
|
+
/** Trigger hop of this process (0 for the interactive pi); children get hop + 1. */
|
|
82
|
+
hop?: number;
|
|
83
|
+
/**
|
|
84
|
+
* Presence of every pi on the machine and this process's own identity. With both, a project's
|
|
85
|
+
* checks and push evaluations are run by a pi open in that project (preferring the session
|
|
86
|
+
* that created the rules); without them the machine leader does everything (tests).
|
|
87
|
+
*/
|
|
88
|
+
presence?: () => PresenceEntry[];
|
|
89
|
+
self?: PresenceSelf;
|
|
90
|
+
/** Whether this process is the machine leader right now (used outside tick(), e.g. for pushes). */
|
|
91
|
+
isLeader?: () => boolean;
|
|
92
|
+
/** Default cap on a check/action sub-agent (`[triggers] run_timeout_secs`); a rule's `timeoutMs` overrides it. */
|
|
93
|
+
runTimeoutMs?: number;
|
|
94
|
+
/** How long a push deferred to another process waits for that process to claim it (see `admit`). */
|
|
95
|
+
deferredTakeoverMs?: number;
|
|
96
|
+
/**
|
|
97
|
+
* Checks and actions in flight at once. Spawning every accepted trigger concurrently is
|
|
98
|
+
* bounded in practice by a person watching the feed; a headless host has nobody watching, and a
|
|
99
|
+
* server pushing distinct events would otherwise open one sub-agent per event. Only used to size
|
|
100
|
+
* a private counter when `slots` is absent.
|
|
101
|
+
*/
|
|
102
|
+
maxConcurrent?: number;
|
|
103
|
+
/**
|
|
104
|
+
* The process-wide sub-agent admission counter, shared with the scheduler and the /goal evaluator
|
|
105
|
+
* (src/slots.ts). Omitted, the trigger runtime bounds only its own checks against `maxConcurrent`
|
|
106
|
+
* — which is the bug this exists to close, so production always passes one.
|
|
107
|
+
*/
|
|
108
|
+
slots?: SubagentSlots;
|
|
109
|
+
/** Today's automation spend against `[limits] daily_budget_usd`, shared with the scheduler. */
|
|
110
|
+
budget?: () => { spent: number; cap: number; over: boolean };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const DEFAULT_MAX_CONCURRENT_CHECKS = 3;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Pushes held while every slot is busy. Retries drain at most `maxConcurrent` (3) per scheduler
|
|
117
|
+
* tick (30s), so 32 is about five minutes of backlog — the width of the dedup window that defines
|
|
118
|
+
* a push's identity. Past that the held events are older than the window they would collapse in,
|
|
119
|
+
* and holding more of them only delays the moment we admit the machine cannot keep up: the oldest
|
|
120
|
+
* is dropped, with an audit row, instead of the list growing until the process dies.
|
|
121
|
+
*/
|
|
122
|
+
export const MAX_PENDING_PUSHES = 32;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Backoff for a rule whose check keeps failing — the same shape and numbers as the scheduler's job
|
|
126
|
+
* backoff (`FAILURE_BACKOFF_*` in scheduler.ts), which exists for exactly this: a check that failed
|
|
127
|
+
* three times in a row is failing for a reason the next poll will not fix, and each attempt bills.
|
|
128
|
+
*/
|
|
129
|
+
export const CHECK_FAILURE_BACKOFF_AFTER = 3;
|
|
130
|
+
export const CHECK_FAILURE_BACKOFF_BASE_MS = 5 * 60_000;
|
|
131
|
+
export const CHECK_FAILURE_BACKOFF_MAX_MS = 6 * 60 * 60_000;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Transcripts are kept per project, not in one directory for the machine: a shared budget meant
|
|
135
|
+
* three projects polling every ten minutes exhausted it within hours, taking the evidence for
|
|
136
|
+
* "why did this rule not match?" with them.
|
|
137
|
+
*
|
|
138
|
+
* The basename alone was not per project: `~/a/web` and `~/b/web` landed in one directory, back to
|
|
139
|
+
* one shared budget of 40 — and each project's prune deleted the other's evidence. So the readable
|
|
140
|
+
* basename keeps its place and a short digest of the resolved path makes it unique. Resolved the way
|
|
141
|
+
* everything else here compares paths (`realpathish`), so a worktree reached through a symlink is
|
|
142
|
+
* still the same project.
|
|
143
|
+
*/
|
|
144
|
+
export function triggerSessionKey(cwd: string): string {
|
|
145
|
+
const base = path.basename(cwd || "unknown").replace(/[^A-Za-z0-9._-]/g, "_") || "unknown";
|
|
146
|
+
const digest = createHash("sha256").update(realpathish(cwd || "unknown")).digest("hex").slice(0, 8);
|
|
147
|
+
return `triggers-${base}-${digest}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Check transcripts kept per project, as docs/configuration.md promises. */
|
|
151
|
+
export const TRIGGER_SESSIONS_KEPT = 40;
|
|
152
|
+
|
|
153
|
+
export const DEFAULT_TRIGGER_RUN_TIMEOUT_MS = 15 * 60_000;
|
|
154
|
+
/**
|
|
155
|
+
* A push handed to the presence-chosen owner is only really handled if that process has the MCP
|
|
156
|
+
* server connected — it authenticates per process, so it may not. The receiving process waits this
|
|
157
|
+
* long for the owner to claim the machine-wide dedup key and takes the push back if it does not.
|
|
158
|
+
*/
|
|
159
|
+
export const DEFAULT_DEFERRED_TAKEOVER_MS = 5_000;
|
|
160
|
+
|
|
161
|
+
/** The engine prefixes `[Trigger <trace>] ` and injects the payload summary itself, capped. */
|
|
162
|
+
export function promotionBody(trigger: Trigger, summary: string): string {
|
|
163
|
+
// Capped, never reflowed: a promoted result is often a file, a diff or test output, and rewrapping
|
|
164
|
+
// it is how a diff stops applying and a stack trace stops pointing anywhere. Truncation respects
|
|
165
|
+
// character boundaries, so the cap cannot cut a multi-byte character in half.
|
|
166
|
+
return `[Trigger ${trigger.traceId}] ${capRedacted(summary, SUMMARY_CAP_CHARS)}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A sub-agent result promoted into the chat: `<source> fired <event>.\nResult: …`, so the chat says
|
|
171
|
+
* what fired and not only what came back — a result with no cause reads as the agent talking to
|
|
172
|
+
* itself. The rendered body is then capped at `SUMMARY_CAP_CHARS` characters — the same cap an audit
|
|
173
|
+
* row's summary gets, and characters rather than bytes, so it cannot cut a multi-byte one in half.
|
|
174
|
+
*/
|
|
175
|
+
export function promotionSummaryBody(trigger: Trigger, summary: string): string {
|
|
176
|
+
return capRedacted(`[Trigger ${trigger.traceId}] ${trigger.sourceLabel} fired ${trigger.eventLabel}.\nResult: ${summary}`, SUMMARY_CAP_CHARS);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The envelope fields carried on every audit row, in every state a trigger passes through. Without
|
|
181
|
+
* the idempotency key on each of them, "which pushes collapsed into which" cannot be answered after
|
|
182
|
+
* the fact — and that question is the whole reason the dedup window is allowed to drop anything.
|
|
183
|
+
* Bounded and redacted: a key is caller-supplied text.
|
|
184
|
+
*/
|
|
185
|
+
function envelopeOf(trigger: Trigger): Record<string, unknown> {
|
|
186
|
+
return { idempotency_key: capRedacted(trigger.idempotencyKey, 256), replacement_policy: trigger.replacementPolicy, received_at: trigger.receivedAt };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export class TriggerRuntime {
|
|
190
|
+
readonly store: TriggerStore;
|
|
191
|
+
private readonly jobStore: JobStore;
|
|
192
|
+
private readonly getSession: TriggerRuntimeOptions["getSession"];
|
|
193
|
+
private readonly hooks: TriggerRuntimeHooks;
|
|
194
|
+
private readonly runner: SubagentRunner;
|
|
195
|
+
private readonly now: () => number;
|
|
196
|
+
private readonly dedup: DedupWindow;
|
|
197
|
+
/** Pushes that inject into this window are deduplicated here only: every window reacts. */
|
|
198
|
+
private readonly localDedup = new DedupWindow();
|
|
199
|
+
private readonly ledger: PollLedger;
|
|
200
|
+
private readonly presence?: () => PresenceEntry[];
|
|
201
|
+
private readonly self?: PresenceSelf;
|
|
202
|
+
private readonly isLeader?: () => boolean;
|
|
203
|
+
private readonly hop: number;
|
|
204
|
+
/** Mutable so a config reload at session start takes effect. */
|
|
205
|
+
runTimeoutMs: number;
|
|
206
|
+
private readonly deferredTakeoverMs: number;
|
|
207
|
+
private readonly running = new Map<string, RunningTrigger>();
|
|
208
|
+
/** Pushes refused for want of a slot, waiting for the next tick to retry them (see `queuePush`). */
|
|
209
|
+
private readonly pending = new Map<string, { trigger: Trigger; delivery: TriggerDelivery }>();
|
|
210
|
+
/** Every sub-agent this process starts takes a slot from here, loop runs included. */
|
|
211
|
+
readonly slots: SubagentSlots;
|
|
212
|
+
private readonly budget: () => { spent: number; cap: number; over: boolean };
|
|
213
|
+
pollIntervalSecs: number;
|
|
214
|
+
lastCheckAt = 0;
|
|
215
|
+
/** Bounded, display-only status of the latest periodic check. */
|
|
216
|
+
lastPoll: { at: string; cwd: string; outcome: string; traceId: string; sourceLabel: string; eventLabel: string; summary: string } | undefined;
|
|
217
|
+
dedupedCount = 0;
|
|
218
|
+
cycleSuppressedCount = 0;
|
|
219
|
+
|
|
220
|
+
constructor(opts: TriggerRuntimeOptions) {
|
|
221
|
+
this.store = opts.store;
|
|
222
|
+
this.jobStore = opts.jobStore;
|
|
223
|
+
this.getSession = opts.getSession;
|
|
224
|
+
this.hooks = opts.hooks ?? {};
|
|
225
|
+
this.runner = opts.runner;
|
|
226
|
+
this.now = opts.now ?? Date.now;
|
|
227
|
+
this.pollIntervalSecs = opts.pollIntervalSecs ?? DEFAULT_TRIGGER_POLL_INTERVAL_SECS;
|
|
228
|
+
this.dedup = new DedupWindow(undefined, opts.dedupFile);
|
|
229
|
+
this.ledger = new PollLedger(opts.dedupFile ? path.join(path.dirname(opts.dedupFile), "polls.json") : undefined);
|
|
230
|
+
this.presence = opts.presence;
|
|
231
|
+
this.self = opts.self;
|
|
232
|
+
this.isLeader = opts.isLeader;
|
|
233
|
+
this.hop = opts.hop ?? 0;
|
|
234
|
+
this.runTimeoutMs = opts.runTimeoutMs ?? DEFAULT_TRIGGER_RUN_TIMEOUT_MS;
|
|
235
|
+
this.deferredTakeoverMs = opts.deferredTakeoverMs ?? DEFAULT_DEFERRED_TAKEOVER_MS;
|
|
236
|
+
this.slots = opts.slots ?? new SubagentSlots(opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_CHECKS);
|
|
237
|
+
this.budget = opts.budget ?? (() => ({ spent: 0, cap: 0, over: false }));
|
|
238
|
+
// Audit writes are best-effort — a failed write never fails a trigger; say so once per distinct error.
|
|
239
|
+
let lastReported: string | undefined;
|
|
240
|
+
this.store.onPersistenceError ??= (message) => {
|
|
241
|
+
if (message === lastReported) return;
|
|
242
|
+
lastReported = message;
|
|
243
|
+
this.log(message);
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The log hook is the UI's notifier in pi-loops; it must never turn into a rejection here. */
|
|
248
|
+
private log(message: string): void {
|
|
249
|
+
try {
|
|
250
|
+
this.hooks.log?.(message);
|
|
251
|
+
} catch {
|
|
252
|
+
/* nothing left to report to */
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
runningList(): RunningTrigger[] {
|
|
257
|
+
return [...this.running.values()];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Pushes waiting for a free slot: work that is late, not work that was lost. */
|
|
261
|
+
get pendingPushCount(): number {
|
|
262
|
+
return this.pending.size;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
abort(traceId: string): boolean {
|
|
266
|
+
const r = this.running.get(traceId);
|
|
267
|
+
if (!r) return false;
|
|
268
|
+
r.ctrl.abort();
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
abortAll(): number {
|
|
273
|
+
const n = this.running.size;
|
|
274
|
+
for (const r of this.running.values()) r.ctrl.abort();
|
|
275
|
+
return n;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async stop(): Promise<void> {
|
|
279
|
+
this.abortAll();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The process that must evaluate `rule`; `undefined` when nobody owns it and the leader steps in. */
|
|
283
|
+
private ownerOf(rule: DynamicTriggerRule): PresenceEntry | undefined {
|
|
284
|
+
if (!this.self) return undefined;
|
|
285
|
+
return chooseRuleOwner(this.presence?.() ?? [], rule.cwd, this.self.host, rule.createdBy?.sessionId);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Whether this process evaluates `rule`. A rule belongs to the session that created it, not to
|
|
290
|
+
* its directory, so two pi windows in one repo each check their own rules and a result can only
|
|
291
|
+
* be promoted into the chat that asked for it. `ownIfUnowned` covers only the rules nobody owns —
|
|
292
|
+
* their creating session has closed and their project has no pi open — which the machine leader
|
|
293
|
+
* checks, into the inbox. It never takes a rule away from the process that does own it.
|
|
294
|
+
*/
|
|
295
|
+
ownsRule(rule: DynamicTriggerRule, ownIfUnowned: boolean): boolean {
|
|
296
|
+
if (!this.self) return ownIfUnowned;
|
|
297
|
+
const owner = this.ownerOf(rule);
|
|
298
|
+
return owner ? isSelf(owner, this.self) : ownIfUnowned;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The slot a rule's work occupies machine-wide (poll ledger, push dedup): its creating session
|
|
303
|
+
* while that session is open — nobody else may take it — else the project's shared slot, so a
|
|
304
|
+
* hand-over between processes still never double-checks.
|
|
305
|
+
*/
|
|
306
|
+
private slotOf(rule: DynamicTriggerRule): string {
|
|
307
|
+
const sid = rule.createdBy?.sessionId;
|
|
308
|
+
return sid && this.ownerOf(rule)?.sessionId === sid ? sid : "";
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* This host's enabled rules that govern `cwd`, split into the ones this process owns. A rule
|
|
313
|
+
* created at `~/proj` still governs a pi opened at `~/proj/src` or through a symlink to it:
|
|
314
|
+
* comparing the cwd strings silently diverted those promotions to the inbox.
|
|
315
|
+
*
|
|
316
|
+
* `ownIfUnowned` is passed straight to `ownsRule`: it only adds the rules nobody owns.
|
|
317
|
+
*/
|
|
318
|
+
private rulesFor(cwd: string | undefined, ownIfUnowned: boolean): { applicable: DynamicTriggerRule[]; owned: DynamicTriggerRule[] } {
|
|
319
|
+
const host = this.self?.host ?? os.hostname();
|
|
320
|
+
const all = this.store.load().filter((r) => r.enabled && (!r.host || r.host === host));
|
|
321
|
+
const applicable = cwd ? all.filter((r) => withinProject(r.cwd, cwd)) : all;
|
|
322
|
+
return { applicable, owned: applicable.filter((r) => this.ownsRule(r, ownIfUnowned)) };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Scheduler tick (every process): emit one periodic check per project whose rules this process
|
|
327
|
+
* owns, at most once per poll interval machine-wide. The ledger is claimed per ownership slot,
|
|
328
|
+
* not per project, so two windows in one repo each check their own rules once per interval while
|
|
329
|
+
* a hand-over of the project's own rules still never double-checks.
|
|
330
|
+
*/
|
|
331
|
+
async tick(now: number, leader: boolean): Promise<void> {
|
|
332
|
+
// Before raising new work: the pushes this process refused while it was busy. They are its own
|
|
333
|
+
// backlog, not the leader's, so leadership does not gate them.
|
|
334
|
+
this.retryPending();
|
|
335
|
+
const host = this.self?.host ?? os.hostname();
|
|
336
|
+
const rules = this.store.load().filter((r) => r.enabled && (!r.host || r.host === host) && this.ownsRule(r, leader) && !this.inBackoff(r, now));
|
|
337
|
+
if (!rules.length) return;
|
|
338
|
+
const byCwd = new Map<string, DynamicTriggerRule[]>();
|
|
339
|
+
for (const r of rules) byCwd.set(r.cwd, [...(byCwd.get(r.cwd) ?? []), r]);
|
|
340
|
+
for (const [cwd, group] of byCwd) {
|
|
341
|
+
// A checkout that is gone cannot be checked. The scheduler disables such a cron job with
|
|
342
|
+
// an actionable message; without the same guard here the rules poll — and bill — forever.
|
|
343
|
+
if (cwd && !fs.existsSync(cwd)) {
|
|
344
|
+
for (const rule of group) {
|
|
345
|
+
await this.store.setEnabled(rule.id, false).catch(() => undefined);
|
|
346
|
+
this.store.appendAudit({ cwd, type: "trigger", traceId: newTraceId(), state: "disabled", sourceLabel: "local:dynamic", eventLabel: rule.id, summary: `cwd ${cwd} no longer exists`, details: { reason: "cwd missing" } });
|
|
347
|
+
}
|
|
348
|
+
this.log(`disabled ${group.length} rule(s): cwd ${cwd} no longer exists (re-enable after restoring it)`);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if ([...this.running.values()].some((r) => r.cwd === cwd && r.sourceLabel === "local:dynamic")) continue; // previous check still active
|
|
352
|
+
const bySlot = new Map<string, DynamicTriggerRule[]>();
|
|
353
|
+
for (const r of group) {
|
|
354
|
+
const slot = this.slotOf(r);
|
|
355
|
+
bySlot.set(slot, [...(bySlot.get(slot) ?? []), r]);
|
|
356
|
+
}
|
|
357
|
+
const claimed: DynamicTriggerRule[] = [];
|
|
358
|
+
for (const [slot, owned] of bySlot) {
|
|
359
|
+
if (await this.ledger.claim(`${host}:${cwd}${slot ? `#${slot}` : ""}`, now, this.pollIntervalSecs * 1000)) claimed.push(...owned);
|
|
360
|
+
}
|
|
361
|
+
if (!claimed.length) continue;
|
|
362
|
+
this.lastCheckAt = now;
|
|
363
|
+
// `handle` is documented not to reject; the catch is what makes that true rather than
|
|
364
|
+
// merely intended, since nothing above this would survive one.
|
|
365
|
+
void this.handle(buildPeriodicCheckTrigger(cwd, claimed.length, new Date(now), this.self ? `${this.self.pid}-${this.self.instance}` : ""), "sub_agent", claimed).catch((err: any) =>
|
|
366
|
+
this.log(`check failed: ${err?.message ?? err}`),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Admit one trigger: dedup, audit, deliver. Resolves when the delivery has finished and
|
|
373
|
+
* never rejects: persistence or runner failures are audited (best effort) and logged.
|
|
374
|
+
*/
|
|
375
|
+
async handle(trigger: Trigger, delivery: TriggerDelivery, rules?: DynamicTriggerRule[]): Promise<TriggerOutcome | undefined> {
|
|
376
|
+
// Sub-agents register no notification hooks and run no dynamic checker, so only the
|
|
377
|
+
// interactive process (hop 0) ever handles a trigger. Anything reaching a deeper hop is a
|
|
378
|
+
// cycle and is suppressed, audited as cycle-suppressed.
|
|
379
|
+
if (this.hop > 0) {
|
|
380
|
+
this.cycleSuppressedCount++;
|
|
381
|
+
this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger", traceId: trigger.traceId, state: "cycle_suppressed", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { hop_count: this.hop, delivery, ...envelopeOf(trigger) } });
|
|
382
|
+
this.log(`trigger ${trigger.traceId.slice(0, 8)} cycle_suppressed at hop ${this.hop} (${trigger.sourceLabel} / ${trigger.eventLabel})`);
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
if (trigger.source.kind === "mcp" && delivery === "sub_agent" && !trigger.cwd && !this.getSession().cwd) {
|
|
387
|
+
// No project of our own (the headless host): evaluate the push once per project that has
|
|
388
|
+
// rules, each in that project, as tick() does — never all rules at once in $HOME.
|
|
389
|
+
const host = this.self?.host ?? os.hostname();
|
|
390
|
+
const cwds = [...new Set(this.store.load().filter((r) => r.enabled && (!r.host || r.host === host)).map((r) => r.cwd))];
|
|
391
|
+
if (!cwds.length) return await this.admit(trigger, delivery); // audited as no_rules
|
|
392
|
+
const outcomes = await Promise.all(cwds.map((cwd, i) => this.admit({ ...trigger, cwd, traceId: i ? newTraceId() : trigger.traceId }, delivery)));
|
|
393
|
+
return outcomes.find(Boolean);
|
|
394
|
+
}
|
|
395
|
+
return await this.admit(trigger, delivery, rules);
|
|
396
|
+
} catch (err: any) {
|
|
397
|
+
const message = err?.message ?? String(err);
|
|
398
|
+
this.running.delete(trigger.traceId);
|
|
399
|
+
this.log(`trigger ${trigger.traceId.slice(0, 8)} failed: ${message}`);
|
|
400
|
+
this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger_result", traceId: trigger.traceId, state: "failed", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: message, details: { delivery, error: message, ...envelopeOf(trigger) } });
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private async admit(trigger: Trigger, delivery: TriggerDelivery, rules?: DynamicTriggerRule[]): Promise<TriggerOutcome | undefined> {
|
|
406
|
+
// A sub-agent costs money and a process slot. Both bounds are checked before the dedup claim,
|
|
407
|
+
// so a refused trigger is never marked handled — a periodic check is simply raised again by the
|
|
408
|
+
// next poll, and a push (an event that happened once, which no server repeats) is held below.
|
|
409
|
+
// `inject_summary` puts the push's own text into the chat and runs no model call — its audit
|
|
410
|
+
// row records `cost_usd: 0`. Refusing a free delivery for want of budget is backwards, and
|
|
411
|
+
// the day the cap trips is the day you still want to be told what is arriving.
|
|
412
|
+
const budget = this.budget();
|
|
413
|
+
const gate = delivery === "inject_summary" ? { over: false, spent: budget.spent, cap: budget.cap } : budget;
|
|
414
|
+
// Said out loud, because otherwise "why did this one arrive when everything else stopped" has
|
|
415
|
+
// no answer anywhere.
|
|
416
|
+
if (delivery === "inject_summary" && budget.over) this.log(`trigger ${trigger.traceId.slice(0, 8)} injected despite today's $${budget.cap.toFixed(2)} budget: an injected summary runs no model call`);
|
|
417
|
+
if (gate.over) {
|
|
418
|
+
// Not held: being too busy clears in minutes, but the daily cap can last until midnight,
|
|
419
|
+
// and acting on this morning's deploy event at 23:59 is worse than not acting at all.
|
|
420
|
+
this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger", traceId: trigger.traceId, state: "budget_exceeded", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { ...envelopeOf(trigger), delivery, spent_usd: gate.spent, cap_usd: gate.cap } });
|
|
421
|
+
this.log(`trigger ${trigger.traceId.slice(0, 8)} not run: today's automation has cost $${gate.spent.toFixed(2)} of the $${gate.cap.toFixed(2)} budget`);
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
// The counter is shared with loop runs and the /goal evaluator (src/slots.ts): the cap is on
|
|
425
|
+
// sub-agents, not on checks, so a machine full of loop runs refuses a check too. The slot is
|
|
426
|
+
// reserved here, before the dedup claim below, so the claim cannot outlive its admission.
|
|
427
|
+
let slot: SubagentSlot | undefined;
|
|
428
|
+
if (delivery === "sub_agent") {
|
|
429
|
+
slot = this.slots.acquire();
|
|
430
|
+
if (!slot) {
|
|
431
|
+
// Only a push is kept. Re-queueing a periodic check would be wrong: the next poll evaluates
|
|
432
|
+
// every rule against the world as it is then, so a held one could only run the same check
|
|
433
|
+
// twice. (`handle` has already fanned a project-less push out per project, so each entry
|
|
434
|
+
// here is one event for one project.)
|
|
435
|
+
const queued = trigger.source.kind === "mcp" ? this.queuePush(trigger, delivery) : undefined;
|
|
436
|
+
const { inUseCount, limit } = this.slots;
|
|
437
|
+
this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger", traceId: trigger.traceId, state: "deferred", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { ...envelopeOf(trigger), delivery, reason: `${inUseCount} sub-agent(s) already in flight (max ${limit})`, ...(queued ? { queued, pending: this.pending.size } : {}) } });
|
|
438
|
+
this.log(`trigger ${trigger.traceId.slice(0, 8)} deferred: ${inUseCount} sub-agent(s) already in flight${queued ? " (queued for the next tick)" : ""}`);
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
let prev: Awaited<ReturnType<DedupWindow["check"]>>;
|
|
444
|
+
if (trigger.source.kind === "mcp" && delivery === "sub_agent") {
|
|
445
|
+
// A push evaluated against dynamic rules: by the process that owns them (each session
|
|
446
|
+
// evaluates its own registry), and once per ownership slot machine-wide.
|
|
447
|
+
const cwd = trigger.cwd ?? this.getSession().cwd;
|
|
448
|
+
const scope = this.rulesFor(cwd, this.isLeader?.() ?? true);
|
|
449
|
+
rules = scope.owned;
|
|
450
|
+
if (scope.applicable.length && !scope.owned.length) {
|
|
451
|
+
// The owner may not have this MCP server connected at all — servers authenticate per
|
|
452
|
+
// process — and a push nobody handles is simply lost. Record the hand-off, then take the
|
|
453
|
+
// push back if the owner has not claimed the slot below inside the takeover window; the
|
|
454
|
+
// claim is machine-wide, so at most one process ever evaluates it.
|
|
455
|
+
this.store.appendAudit({ cwd, type: "trigger", traceId: trigger.traceId, state: "deferred", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { delivery, reason: "another pi in this project owns rule evaluation", takeover_after_ms: this.deferredTakeoverMs, ...envelopeOf(trigger) } });
|
|
456
|
+
await new Promise((r) => setTimeout(r, this.deferredTakeoverMs));
|
|
457
|
+
rules = scope.applicable;
|
|
458
|
+
}
|
|
459
|
+
prev = await this.dedup.check(this.pushClaimKey(trigger, cwd, rules), trigger.traceId, this.now(), trigger.replacementPolicy);
|
|
460
|
+
if (!prev && !scope.owned.length && scope.applicable.length) {
|
|
461
|
+
this.store.appendAudit({ cwd, type: "trigger", traceId: trigger.traceId, state: "taken_over", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { delivery, reason: "the owning pi did not claim the push", ...envelopeOf(trigger) } });
|
|
462
|
+
}
|
|
463
|
+
} else if (trigger.source.kind === "mcp") {
|
|
464
|
+
// A push injected into the chat: every window that has the server reacts (every
|
|
465
|
+
// session), so the dedup window is this process's own.
|
|
466
|
+
prev = await this.localDedup.check(trigger.idempotencyKey, trigger.traceId, this.now(), trigger.replacementPolicy);
|
|
467
|
+
} else {
|
|
468
|
+
prev = await this.dedup.check(trigger.idempotencyKey, trigger.traceId, this.now(), trigger.replacementPolicy);
|
|
469
|
+
}
|
|
470
|
+
if (prev) {
|
|
471
|
+
this.dedupedCount++;
|
|
472
|
+
this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger", traceId: trigger.traceId, state: "deduped", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { previous_trace_id: prev.traceId, ...envelopeOf(trigger), replacement_policy: prev.replacementPolicy ?? trigger.replacementPolicy } });
|
|
473
|
+
// `[trigger deduped]` is a feed line; here the only trace was an audit row, so
|
|
474
|
+
// "my webhook fired and nothing happened" had no answer short of reading the JSONL.
|
|
475
|
+
this.log(`trigger ${trigger.traceId.slice(0, 8)} deduped (${trigger.sourceLabel} / ${trigger.eventLabel}): an identical event arrived within the dedup window`);
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger", traceId: trigger.traceId, state: "accepted", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { delivery, evaluator_decision: { outcome: "accept", permission: "allow" }, ...envelopeOf(trigger) } });
|
|
479
|
+
// `return await`, not `return`: a bare return inside a try/finally hands the promise back and
|
|
480
|
+
// runs the finally at once, which would release the slot the moment the sub-agent started.
|
|
481
|
+
if (delivery === "inject_summary") return await this.deliverInjectSummary(trigger);
|
|
482
|
+
if (delivery === "inject_and_run") return await this.deliverInjectAndRun(trigger);
|
|
483
|
+
return await this.deliverSubAgent(trigger, rules);
|
|
484
|
+
} finally {
|
|
485
|
+
// Held for the whole delivery, `deliverSubAgent` included, and released on every path out of
|
|
486
|
+
// it — a torn dedup file, a runner that rejects, an abort — or the pool leaks a slot.
|
|
487
|
+
slot?.release();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** When the event happened, which is what "oldest first" means for a push; `now` if the source sent us an unparseable stamp. */
|
|
492
|
+
private eventTimeOf(trigger: Trigger): number {
|
|
493
|
+
const at = Date.parse(trigger.receivedAt);
|
|
494
|
+
return Number.isNaN(at) ? this.now() : at;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Hold a push that found no free slot. Keyed by idempotency key and project, so a server pushing
|
|
499
|
+
* the same event again while we are busy collapses into the one already waiting instead of filling
|
|
500
|
+
* the list; which copy survives is the envelope's own replacement policy, as in the dedup window.
|
|
501
|
+
*/
|
|
502
|
+
private queuePush(trigger: Trigger, delivery: TriggerDelivery): "queued" | "replaced" | "collapsed" {
|
|
503
|
+
const cwd = trigger.cwd ?? this.getSession().cwd;
|
|
504
|
+
const key = `${trigger.idempotencyKey}@${cwd}`;
|
|
505
|
+
const waiting = this.pending.get(key);
|
|
506
|
+
if (waiting) {
|
|
507
|
+
if (trigger.replacementPolicy !== "latest_replaces") return "collapsed";
|
|
508
|
+
this.pending.set(key, { trigger, delivery });
|
|
509
|
+
return "replaced";
|
|
510
|
+
}
|
|
511
|
+
if (this.pending.size >= MAX_PENDING_PUSHES) {
|
|
512
|
+
const oldest = [...this.pending.entries()].sort((a, b) => this.eventTimeOf(a[1].trigger) - this.eventTimeOf(b[1].trigger))[0];
|
|
513
|
+
this.pending.delete(oldest[0]);
|
|
514
|
+
const lost = oldest[1].trigger;
|
|
515
|
+
// The drop leaves a row of its own: "my webhook fired and nothing happened" must stay
|
|
516
|
+
// answerable from the audit, and the answer here is that the machine never caught up.
|
|
517
|
+
this.store.appendAudit({ cwd: lost.cwd ?? cwd, type: "trigger", traceId: lost.traceId, state: "dropped", sourceLabel: lost.sourceLabel, eventLabel: lost.eventLabel, summary: lost.payloadSummary, details: { ...envelopeOf(lost), delivery: oldest[1].delivery, reason: `${MAX_PENDING_PUSHES} pushes already waiting for a free slot` } });
|
|
518
|
+
this.log(`trigger ${lost.traceId.slice(0, 8)} dropped: ${MAX_PENDING_PUSHES} pushes already waiting for a free slot`);
|
|
519
|
+
}
|
|
520
|
+
this.pending.set(key, { trigger, delivery });
|
|
521
|
+
return "queued";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Retry the held pushes, oldest event first, as many as there are free slots. Each goes back
|
|
526
|
+
* through `handle`, so it is deduped, audited and delivered exactly as if it had just arrived —
|
|
527
|
+
* a deferral, not a bypass — and carries how long it waited so the check can weigh its age.
|
|
528
|
+
*/
|
|
529
|
+
private retryPending(): void {
|
|
530
|
+
// A bound on how many are dequeued at once, not the admission test: `admit` reserves the slot
|
|
531
|
+
// itself, so a push dequeued past the free count is simply audited and queued again.
|
|
532
|
+
let slots = this.slots.free;
|
|
533
|
+
if (slots <= 0 || !this.pending.size) return;
|
|
534
|
+
for (const [key, entry] of [...this.pending.entries()].sort((a, b) => this.eventTimeOf(a[1].trigger) - this.eventTimeOf(b[1].trigger))) {
|
|
535
|
+
if (slots-- <= 0) break;
|
|
536
|
+
this.pending.delete(key);
|
|
537
|
+
const trigger: Trigger = { ...entry.trigger, deferredMs: Math.max(0, this.now() - this.eventTimeOf(entry.trigger)) };
|
|
538
|
+
// `handle` is documented not to reject; the catch is what makes that true rather than
|
|
539
|
+
// merely intended, since nothing above a tick would survive one.
|
|
540
|
+
void this.handle(trigger, entry.delivery).catch((err: any) => this.log(`deferred push ${trigger.traceId.slice(0, 8)} failed: ${err?.message ?? err}`));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Whether `rule`'s next poll is still inside its failure backoff. A check that has failed three
|
|
546
|
+
* times in a row is failing for a reason ten minutes will not fix, and every attempt bills a
|
|
547
|
+
* sub-agent; pushes still reach the rule, because an event is worth one attempt.
|
|
548
|
+
*/
|
|
549
|
+
private inBackoff(rule: DynamicTriggerRule, now: number): boolean {
|
|
550
|
+
const failures = rule.consecutiveFailures ?? 0;
|
|
551
|
+
if (failures < CHECK_FAILURE_BACKOFF_AFTER || !rule.lastCheckFailedAt) return false;
|
|
552
|
+
const wait = backoffWaitMs(failures, CHECK_FAILURE_BACKOFF_AFTER, CHECK_FAILURE_BACKOFF_BASE_MS, CHECK_FAILURE_BACKOFF_MAX_MS);
|
|
553
|
+
return now - Date.parse(rule.lastCheckFailedAt) < wait;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Record a check's outcome on the rules it evaluated, and say in the audit when that starts holding a poll back. */
|
|
557
|
+
private async recordCheckOutcome(rules: DynamicTriggerRule[], ok: boolean, cwd: string): Promise<void> {
|
|
558
|
+
const ids = rules.map((r) => r.id);
|
|
559
|
+
if (ok && !rules.some((r) => r.consecutiveFailures)) return; // nothing to clear: no lock, no write
|
|
560
|
+
const nowIso = stamp(this.now());
|
|
561
|
+
const failed = await this.store.mutate((all) => {
|
|
562
|
+
const out: DynamicTriggerRule[] = [];
|
|
563
|
+
for (const rule of all) {
|
|
564
|
+
if (!ids.includes(rule.id)) continue;
|
|
565
|
+
if (ok) {
|
|
566
|
+
rule.consecutiveFailures = undefined;
|
|
567
|
+
rule.lastCheckFailedAt = undefined;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
rule.consecutiveFailures = (rule.consecutiveFailures ?? 0) + 1;
|
|
571
|
+
rule.lastCheckFailedAt = nowIso;
|
|
572
|
+
out.push(rule);
|
|
573
|
+
}
|
|
574
|
+
return out;
|
|
575
|
+
});
|
|
576
|
+
const backing = failed.filter((r) => (r.consecutiveFailures ?? 0) >= CHECK_FAILURE_BACKOFF_AFTER);
|
|
577
|
+
if (!backing.length) return;
|
|
578
|
+
const worst = Math.max(...backing.map((r) => r.consecutiveFailures ?? 0));
|
|
579
|
+
this.store.appendAudit({ cwd, type: "trigger", traceId: newTraceId(), state: "backoff", sourceLabel: "local:dynamic", eventLabel: backing.map((r) => r.id).join(" "), summary: `${backing.length} rule(s) checked with a widening gap after ${worst} failed check(s) in a row`, details: { rule_ids: backing.map((r) => r.id), consecutive_failures: worst } });
|
|
580
|
+
this.log(`${backing.length} trigger rule(s) failed ${worst} checks in a row; polling them with a widening gap`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* The machine-wide claim a pushed evaluation takes. Keyed by the project (realpath, so two
|
|
585
|
+
* windows reaching it by different paths agree) and by the ownership slots of the rules being
|
|
586
|
+
* evaluated, so windows checking *different* rules of one project both react while a deferred
|
|
587
|
+
* push and its owner still contend for the same key.
|
|
588
|
+
*/
|
|
589
|
+
private pushClaimKey(trigger: Trigger, cwd: string, rules: DynamicTriggerRule[]): string {
|
|
590
|
+
const slots = [...new Set(rules.map((r) => this.slotOf(r)))].sort().join(",");
|
|
591
|
+
return `${trigger.idempotencyKey}@${this.self?.host ?? os.hostname()}:${realpathish(cwd)}${slots ? `#${slots}` : ""}`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private async deliverInjectSummary(trigger: Trigger): Promise<TriggerOutcome> {
|
|
595
|
+
const start = this.now();
|
|
596
|
+
const summary = trigger.payloadSummary ?? "";
|
|
597
|
+
let promoted = false;
|
|
598
|
+
if (summary) {
|
|
599
|
+
const target = (await this.hooks.onPromote?.(promotionBody(trigger, summary), trigger)) ?? "chat";
|
|
600
|
+
promoted = target === "chat";
|
|
601
|
+
this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger_promotion", traceId: trigger.traceId, state: target === "chat" ? "promoted" : "redirected", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary, details: { prefix_injected: true, delivery: "inject_summary", to: target, ...envelopeOf(trigger) } });
|
|
602
|
+
}
|
|
603
|
+
// `over_budget` on a completed row is the audit's answer to "everything else stopped today —
|
|
604
|
+
// why did this run": because it cost nothing.
|
|
605
|
+
const spend = this.budget();
|
|
606
|
+
this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger_result", traceId: trigger.traceId, state: "completed", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary, details: { delivery: "inject_summary", cost_usd: 0, ...(spend.over ? { over_budget: true } : {}), ...envelopeOf(trigger) } });
|
|
607
|
+
const outcome: TriggerOutcome = { trigger, delivery: "inject_summary", ok: true, matchedRules: [], summary, durationMs: this.now() - start, cost: 0, promoted };
|
|
608
|
+
this.hooks.onFinished?.(outcome);
|
|
609
|
+
return outcome;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private async deliverInjectAndRun(trigger: Trigger): Promise<TriggerOutcome> {
|
|
613
|
+
const start = this.now();
|
|
614
|
+
const prompt = `[Trigger ${trigger.traceId}] ${trigger.payloadSummary ?? `${trigger.sourceLabel} fired: ${trigger.eventLabel}`}`;
|
|
615
|
+
const target = (await this.hooks.onInjectAndRun?.(prompt, trigger)) ?? "chat";
|
|
616
|
+
this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger_result", traceId: trigger.traceId, state: "completed", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { delivery: "inject_and_run", prefix_injected: true, cost_usd: 0, to: target, ...envelopeOf(trigger) } });
|
|
617
|
+
const outcome: TriggerOutcome = { trigger, delivery: "inject_and_run", ok: true, matchedRules: [], summary: trigger.payloadSummary ?? "", durationMs: this.now() - start, cost: 0, promoted: target === "chat" };
|
|
618
|
+
this.hooks.onFinished?.(outcome);
|
|
619
|
+
return outcome;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* The project an audit row of this trigger belongs to. Resolved once per delivery: a check takes
|
|
624
|
+
* minutes, and the session's cwd can move under it (`/resume`, a project switch), which used to
|
|
625
|
+
* split one trace across two projects in `/triggers audit`.
|
|
626
|
+
*/
|
|
627
|
+
private cwdOf(trigger: Trigger): string {
|
|
628
|
+
return trigger.cwd ?? this.getSession().cwd;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private async deliverSubAgent(trigger: Trigger, evaluate?: DynamicTriggerRule[]): Promise<TriggerOutcome> {
|
|
632
|
+
const start = this.now();
|
|
633
|
+
const session = this.getSession();
|
|
634
|
+
const cwd = this.cwdOf(trigger);
|
|
635
|
+
// `evaluate` is what the caller already resolved (the tick's claimed rules, a taken-over
|
|
636
|
+
// push); otherwise this is a direct handle() and we take the rules of this project we own.
|
|
637
|
+
const rules = evaluate ?? this.rulesFor(trigger.cwd, this.isLeader?.() ?? true).owned;
|
|
638
|
+
if (!rules.length) {
|
|
639
|
+
this.store.appendAudit({ cwd, type: "trigger_result", traceId: trigger.traceId, state: "no_rules", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: trigger.payloadSummary, details: { delivery: "sub_agent", ...envelopeOf(trigger) } });
|
|
640
|
+
const outcome: TriggerOutcome = { trigger, delivery: "sub_agent", ok: true, matchedRules: [], summary: "no enabled dynamic trigger rules", durationMs: 0, cost: 0, promoted: false };
|
|
641
|
+
this.hooks.onFinished?.(outcome);
|
|
642
|
+
return outcome;
|
|
643
|
+
}
|
|
644
|
+
const prompt = renderDynamicTriggerPrompt(trigger, rules);
|
|
645
|
+
const ctrl = new AbortController();
|
|
646
|
+
const running: RunningTrigger = { traceId: trigger.traceId, sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, startedAt: stamp(start), promptPreview: previewRedacted(prompt, 80), cwd, ctrl }; // 80 characters is a banner, not a prompt
|
|
647
|
+
this.running.set(trigger.traceId, running);
|
|
648
|
+
this.hooks.onStarted?.(running);
|
|
649
|
+
this.store.appendAudit({ cwd, type: "trigger_result", traceId: trigger.traceId, state: "running", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, details: { rule_count: rules.length, cwd, ...envelopeOf(trigger) } });
|
|
650
|
+
|
|
651
|
+
// The check runs with the model the rules were created under, not whatever the process that
|
|
652
|
+
// happens to own the timer is using. Both come from the first rule that recorded a model,
|
|
653
|
+
// because a thinking level belongs to the model it was chosen for: pairing them with a level
|
|
654
|
+
// from some other rule is how a check asks for a budget the model does not have.
|
|
655
|
+
const withModel = rules.find((r) => r.model);
|
|
656
|
+
const model = withModel?.model ?? session.model;
|
|
657
|
+
const thinking = withModel?.thinking ?? session.thinking;
|
|
658
|
+
let result: RunnerResult;
|
|
659
|
+
try {
|
|
660
|
+
// A check runs every rule of the project, so the longest per-rule cap wins.
|
|
661
|
+
const timeoutMs = rules.reduce((max, r) => Math.max(max, r.timeoutMs ?? 0), 0) || this.runTimeoutMs;
|
|
662
|
+
result = await this.runner({ cwd, prompt, model, thinking, timeoutMs, signal: ctrl.signal, sessionDir: this.jobStore.sessionDirFor(triggerSessionKey(cwd)), hop: this.hop + 1, parentSessionId: session.sessionId, parentCwd: session.cwd, kind: "trigger", traceId: trigger.traceId });
|
|
663
|
+
} catch (err: any) {
|
|
664
|
+
result = failedRun(err?.message ?? String(err));
|
|
665
|
+
} finally {
|
|
666
|
+
this.running.delete(trigger.traceId);
|
|
667
|
+
}
|
|
668
|
+
// Per project, not one budget for the machine: three projects polling every ten minutes used
|
|
669
|
+
// to exhaust a shared 40 within a couple of hours, taking the evidence with them. The key has
|
|
670
|
+
// to be the one the transcripts were written under, or the prune looks in a directory that
|
|
671
|
+
// does not exist and silently keeps everything.
|
|
672
|
+
this.jobStore.pruneSessions(triggerSessionKey(cwd), TRIGGER_SESSIONS_KEPT);
|
|
673
|
+
|
|
674
|
+
const summary = result.text.trim();
|
|
675
|
+
// A check killed by the run timeout (or aborted) has usually already *executed* the matching
|
|
676
|
+
// rule's action — posting the comment, kicking the deploy — so the matched ids in whatever it
|
|
677
|
+
// managed to say still have to disarm a fire-once rule; otherwise the next poll does it again.
|
|
678
|
+
const matchedIds = extractDynamicRuleIds(summary);
|
|
679
|
+
const matchedRules = rules.filter((r) => matchedIds.includes(r.id));
|
|
680
|
+
if (matchedRules.length) await this.store.markFired(matchedRules.map((r) => r.id));
|
|
681
|
+
const quiet = result.ok && matchedRules.length === 0;
|
|
682
|
+
const state = ctrl.signal.aborted ? "aborted" : result.ok ? "completed" : "failed";
|
|
683
|
+
this.store.appendAudit({
|
|
684
|
+
cwd,
|
|
685
|
+
type: "trigger_result",
|
|
686
|
+
traceId: trigger.traceId,
|
|
687
|
+
state,
|
|
688
|
+
sourceLabel: trigger.sourceLabel,
|
|
689
|
+
eventLabel: trigger.eventLabel,
|
|
690
|
+
summary: result.ok ? summary || NO_MATCH_SENTINEL : result.errorMessage,
|
|
691
|
+
details: { delivery: "sub_agent", matched_rule_ids: matchedIds, quiet, cost_usd: result.usage.cost, exit_code: result.exitCode, session_file: result.sessionFile, ...envelopeOf(trigger) },
|
|
692
|
+
});
|
|
693
|
+
// An abort is the user's decision about this run, not evidence that the rule's check is broken,
|
|
694
|
+
// so it neither counts against the rule nor clears what earlier failures recorded.
|
|
695
|
+
if (!ctrl.signal.aborted) await this.recordCheckOutcome(rules, result.ok, cwd);
|
|
696
|
+
|
|
697
|
+
let promoted = false;
|
|
698
|
+
const promoteRules = matchedRules.filter((r) => r.promoteToChat);
|
|
699
|
+
if (result.ok && promoteRules.length) {
|
|
700
|
+
const target = (await this.hooks.onPromote?.(promotionSummaryBody(trigger, summary), trigger)) ?? "chat";
|
|
701
|
+
promoted = target === "chat";
|
|
702
|
+
this.store.appendAudit({ cwd, type: "trigger_promotion", traceId: trigger.traceId, state: target === "chat" ? "promoted" : "redirected", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary, details: { prefix_injected: true, rule_ids: promoteRules.map((r) => r.id), to: target, template_name: "default", ...envelopeOf(trigger) } });
|
|
703
|
+
} else if (result.ok && matchedRules.length) {
|
|
704
|
+
this.store.appendAudit({ cwd, type: "trigger_promotion", traceId: trigger.traceId, state: "skipped", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, details: { reason: "no matched rule has promote_to_chat", ...envelopeOf(trigger) } });
|
|
705
|
+
}
|
|
706
|
+
if (trigger.sourceLabel === "local:dynamic") this.lastPoll = { at: stamp(this.now()), cwd, outcome: state === "completed" ? (quiet ? "no match" : `matched ${matchedRules.length}`) : state, traceId: trigger.traceId, sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary: previewRedacted(result.ok ? summary || NO_MATCH_SENTINEL : (result.errorMessage ?? ""), 160) };
|
|
707
|
+
const outcome: TriggerOutcome = { trigger, delivery: "sub_agent", ok: result.ok, matchedRules, summary, error: result.ok ? undefined : result.errorMessage, durationMs: this.now() - start, cost: result.usage.cost, promoted, sessionFile: result.sessionFile };
|
|
708
|
+
this.hooks.onFinished?.(outcome);
|
|
709
|
+
return outcome;
|
|
710
|
+
}
|
|
711
|
+
}
|