@alphacoder-v0/pi-loops 0.22.8 → 0.24.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/CHANGELOG.md +664 -0
- package/CONTRACT.md +7 -7
- package/README.md +91 -63
- package/README.zh-CN.md +59 -39
- package/docs/cli.md +272 -83
- package/docs/configuration.md +86 -24
- package/docs/design.md +395 -56
- package/docs/downstream.md +2 -2
- package/docs/goal.md +12 -3
- package/docs/hooks.md +6 -7
- package/docs/loops.md +288 -59
- package/docs/mcp.md +22 -14
- package/docs/recipes.md +6 -0
- package/docs/screenshot.png +0 -0
- package/docs/triggers.md +120 -26
- package/docs/troubleshooting.md +37 -26
- package/docs/web-ui-parity.md +691 -65
- package/package.json +4 -3
- package/recipes/autoresearch/recipe.toml +1 -0
- package/recipes/changelog-draft/recipe.toml +1 -0
- package/recipes/ci-sweeper/recipe.toml +1 -0
- package/recipes/daily-digest/recipe.toml +1 -0
- package/recipes/deps-sweeper/recipe.toml +1 -0
- package/recipes/ecosystem/recipe.toml +1 -0
- package/recipes/issue-loop/recipe.toml +1 -0
- package/recipes/pr-watch/recipe.toml +1 -0
- package/skills/pi-loops/SKILL.md +6 -4
- package/src/activity.ts +199 -0
- package/src/archive.ts +21 -9
- package/src/args.ts +6 -2
- package/src/cli-entry.mjs +12 -3
- package/src/cli.ts +746 -125
- package/src/config.ts +53 -5
- package/src/conversation-rows.ts +469 -0
- package/src/danger.ts +227 -6
- package/src/engine.ts +1240 -0
- package/src/goal.ts +71 -0
- package/src/held-read.ts +27 -0
- package/src/hooks.ts +49 -18
- package/src/host-control-channel.ts +455 -40
- package/src/host-control.ts +602 -111
- package/src/host-entry.mjs +5 -3
- package/src/host-runtime.ts +455 -42
- package/src/host.ts +659 -123
- package/src/inbox-card.ts +278 -0
- package/src/inbox.ts +139 -62
- package/src/job-edit.ts +7 -1
- package/src/job-health.ts +14 -2
- package/src/job-lines.ts +201 -13
- package/src/job-owner.ts +74 -13
- package/src/job-tune.ts +110 -0
- package/src/lease.ts +537 -0
- package/src/lock.ts +7 -2
- package/src/mcp-pool.ts +66 -14
- package/src/mcp.ts +1 -1
- package/src/open-file.ts +54 -0
- package/src/page-children.ts +1507 -0
- package/src/page-devices.ts +192 -0
- package/src/page-server.ts +3150 -0
- package/src/path-precheck.ts +215 -0
- package/src/paths.ts +24 -0
- package/src/pi-floor.ts +10 -0
- package/src/pi-loops.ts +1301 -450
- package/src/pi-resolver.d.mts +1 -0
- package/src/pi-resolver.mjs +110 -10
- package/src/presence.ts +12 -63
- package/src/protocol.ts +10 -1
- package/src/recipe.ts +43 -1
- package/src/requests.ts +354 -0
- package/src/rule-lines.ts +68 -0
- package/src/run-card.ts +105 -0
- package/src/runner.ts +18 -1
- package/src/schedule.ts +1 -2
- package/src/scheduler.ts +595 -245
- package/src/sdk-runner.ts +321 -27
- package/src/seen.ts +278 -0
- package/src/session-head.ts +21 -0
- package/src/share.ts +12 -2
- package/src/snapshot.ts +3 -2
- package/src/store.ts +81 -8
- package/src/tools.ts +196 -65
- package/src/trigger-runtime.ts +164 -127
- package/src/triggers.ts +39 -7
- package/src/trust.ts +46 -2
- package/src/ui-prefs.ts +16 -5
- package/src/watch-plan.ts +174 -0
- package/src/web.d.mts +3 -0
- package/src/web.mjs +2260 -2340
- package/src/window-executor.ts +459 -0
- package/src/window-guard.ts +158 -0
- package/docs/agents/issue-tracker.md +0 -34
package/src/trigger-runtime.ts
CHANGED
|
@@ -9,12 +9,14 @@ import * as os from "node:os";
|
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { realpathish } from "./paths.ts";
|
|
12
|
-
import {
|
|
12
|
+
import { sameProject, withinProject } from "./paths.ts";
|
|
13
13
|
import { capRedacted, previewRedacted } from "./redact.ts";
|
|
14
14
|
import { type RunnerResult, type SubagentRunner, failedRun } from "./runner.ts";
|
|
15
15
|
import { type SubagentSlot, SubagentSlots } from "./slots.ts";
|
|
16
16
|
import type { JobStore } from "./store.ts";
|
|
17
|
+
import type { Inbox } from "./inbox.ts";
|
|
17
18
|
import { backoffWaitMs, stamp } from "./schedule.ts";
|
|
19
|
+
import { type Look, PathLooks, watchOf } from "./path-precheck.ts";
|
|
18
20
|
import {
|
|
19
21
|
DEFAULT_TRIGGER_POLL_INTERVAL_SECS,
|
|
20
22
|
DedupWindow,
|
|
@@ -24,11 +26,15 @@ import {
|
|
|
24
26
|
type DynamicTriggerRule,
|
|
25
27
|
type Trigger,
|
|
26
28
|
TriggerStore,
|
|
29
|
+
checkDecidedFinding,
|
|
27
30
|
buildPeriodicCheckTrigger,
|
|
28
31
|
extractDynamicRuleIds,
|
|
29
32
|
renderDynamicTriggerPrompt,
|
|
30
33
|
newTraceId } from "./triggers.ts";
|
|
31
34
|
|
|
35
|
+
/** Bounded, display-only status of a periodic check. */
|
|
36
|
+
export type LastPoll = { at: string; cwd: string; outcome: string; traceId: string; sourceLabel: string; eventLabel: string; summary: string };
|
|
37
|
+
|
|
32
38
|
export type TriggerDelivery = "sub_agent" | "inject_summary" | "inject_and_run";
|
|
33
39
|
|
|
34
40
|
export interface RunningTrigger {
|
|
@@ -54,12 +60,15 @@ export interface TriggerOutcome {
|
|
|
54
60
|
sessionFile?: string;
|
|
55
61
|
}
|
|
56
62
|
|
|
57
|
-
/** Where a promotion / injection
|
|
58
|
-
export type PromoteTarget = "chat" | "inbox";
|
|
63
|
+
/** Where a promotion / injection went: the chat of this process, the queue of the conversation that created the rule (src/requests.ts), the inbox, or nowhere because an earlier one with its key is still queued. */
|
|
64
|
+
export type PromoteTarget = "chat" | "queued" | "inbox" | "collapsed";
|
|
59
65
|
|
|
60
66
|
export interface TriggerRuntimeHooks {
|
|
61
|
-
/**
|
|
62
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Insert text into the parent chat context (no model turn). Already carries the `[Trigger …]` prefix.
|
|
69
|
+
* `forSession` is the conversation that created the promoting rules, when they agree on one. Returns where it went.
|
|
70
|
+
*/
|
|
71
|
+
onPromote?: (content: string, trigger: Trigger, forSession?: string) => PromoteTarget | Promise<PromoteTarget>;
|
|
63
72
|
/** Inject `prompt` into the parent chat and run one model turn. Returns where it went. */
|
|
64
73
|
onInjectAndRun?: (prompt: string, trigger: Trigger) => PromoteTarget | Promise<PromoteTarget>;
|
|
65
74
|
onStarted?: (running: RunningTrigger) => void;
|
|
@@ -80,19 +89,8 @@ export interface TriggerRuntimeOptions {
|
|
|
80
89
|
dedupFile?: string;
|
|
81
90
|
/** Trigger hop of this process (0 for the interactive pi); children get hop + 1. */
|
|
82
91
|
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
92
|
/** Default cap on a check/action sub-agent (`[triggers] run_timeout_secs`); a rule's `timeoutMs` overrides it. */
|
|
93
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
94
|
/**
|
|
97
95
|
* Checks and actions in flight at once. Spawning every accepted trigger concurrently is
|
|
98
96
|
* bounded in practice by a person watching the feed; a headless host has nobody watching, and a
|
|
@@ -108,10 +106,20 @@ export interface TriggerRuntimeOptions {
|
|
|
108
106
|
slots?: SubagentSlots;
|
|
109
107
|
/** Today's automation spend against `[limits] daily_budget_usd`, shared with the scheduler. */
|
|
110
108
|
budget?: () => { spent: number; cap: number; over: boolean };
|
|
109
|
+
/**
|
|
110
|
+
* Where a matched rule's result is filed, whatever `promote_to_chat` says (docs/triggers.md).
|
|
111
|
+
* Both processes pass the scheduler's inbox; omitted, a result is only audited.
|
|
112
|
+
*/
|
|
113
|
+
inbox?: Pick<Inbox, "appendOnce">;
|
|
114
|
+
/** The `stat` a waiting rule's look uses; tests inject one that hangs. */
|
|
115
|
+
stat?: (p: string) => Promise<unknown>;
|
|
111
116
|
}
|
|
112
117
|
|
|
113
118
|
export const DEFAULT_MAX_CONCURRENT_CHECKS = 3;
|
|
114
119
|
|
|
120
|
+
/** The scheduler's tick, which `tick` here runs on (`DEFAULT_TICK_MS` in scheduler.ts): no poll comes sooner. */
|
|
121
|
+
const POLL_TICK_MS = 30_000;
|
|
122
|
+
|
|
115
123
|
/**
|
|
116
124
|
* Pushes held while every slot is busy. Retries drain at most `maxConcurrent` (3) per scheduler
|
|
117
125
|
* tick (30s), so 32 is about five minutes of backlog — the width of the dedup window that defines
|
|
@@ -151,12 +159,6 @@ export function triggerSessionKey(cwd: string): string {
|
|
|
151
159
|
export const TRIGGER_SESSIONS_KEPT = 40;
|
|
152
160
|
|
|
153
161
|
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
162
|
|
|
161
163
|
/** The engine prefixes `[Trigger <trace>] ` and injects the payload summary itself, capped. */
|
|
162
164
|
export function promotionBody(trigger: Trigger, summary: string): string {
|
|
@@ -176,12 +178,30 @@ export function promotionSummaryBody(trigger: Trigger, summary: string): string
|
|
|
176
178
|
return capRedacted(`[Trigger ${trigger.traceId}] ${trigger.sourceLabel} fired ${trigger.eventLabel}.\nResult: ${summary}`, SUMMARY_CAP_CHARS);
|
|
177
179
|
}
|
|
178
180
|
|
|
181
|
+
/**
|
|
182
|
+
* A promoted result no conversation can take, filed in the inbox: what both processes do when the
|
|
183
|
+
* promotion falls back. A rule check decided its finding already (checkDecidedFinding), filed or
|
|
184
|
+
* skipped as a repeat; a push with no rule did not, and is filed once by its run.
|
|
185
|
+
*/
|
|
186
|
+
export async function promotionToInbox(inbox: Pick<Inbox, "appendOnce">, audit: Pick<TriggerStore, "listAudit">, content: string, trigger: { sourceLabel: string; traceId: string }, where: { cwd: string; sessionId?: string }): Promise<void> {
|
|
187
|
+
if (checkDecidedFinding(audit, trigger.traceId)) return;
|
|
188
|
+
const source = `trigger:${trigger.sourceLabel}`;
|
|
189
|
+
await inbox.appendOnce({ source, text: content.replace(/^\[Trigger [^\]]+\]\s*/, ""), run_id: trigger.traceId, job_id: trigger.sourceLabel, cwd: where.cwd, session_id: where.sessionId }, { run_id: trigger.traceId, source });
|
|
190
|
+
}
|
|
191
|
+
|
|
179
192
|
/**
|
|
180
193
|
* The envelope fields carried on every audit row, in every state a trigger passes through. Without
|
|
181
194
|
* the idempotency key on each of them, "which pushes collapsed into which" cannot be answered after
|
|
182
195
|
* the fact — and that question is the whole reason the dedup window is allowed to drop anything.
|
|
183
196
|
* Bounded and redacted: a key is caller-supplied text.
|
|
184
197
|
*/
|
|
198
|
+
/** Rules by the conversation that created them: one check's result goes to each of those once. */
|
|
199
|
+
function byCreator(rules: readonly DynamicTriggerRule[]): Map<string | undefined, DynamicTriggerRule[]> {
|
|
200
|
+
const out = new Map<string | undefined, DynamicTriggerRule[]>();
|
|
201
|
+
for (const r of rules) out.set(r.createdBy?.sessionId, [...(out.get(r.createdBy?.sessionId) ?? []), r]);
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
185
205
|
function envelopeOf(trigger: Trigger): Record<string, unknown> {
|
|
186
206
|
return { idempotency_key: capRedacted(trigger.idempotencyKey, 256), replacement_policy: trigger.replacementPolicy, received_at: trigger.receivedAt };
|
|
187
207
|
}
|
|
@@ -197,25 +217,28 @@ export class TriggerRuntime {
|
|
|
197
217
|
/** Pushes that inject into this window are deduplicated here only: every window reacts. */
|
|
198
218
|
private readonly localDedup = new DedupWindow();
|
|
199
219
|
private readonly ledger: PollLedger;
|
|
200
|
-
private readonly presence?: () => PresenceEntry[];
|
|
201
|
-
private readonly self?: PresenceSelf;
|
|
202
|
-
private readonly isLeader?: () => boolean;
|
|
203
220
|
private readonly hop: number;
|
|
204
221
|
/** Mutable so a config reload at session start takes effect. */
|
|
205
222
|
runTimeoutMs: number;
|
|
206
|
-
private readonly deferredTakeoverMs: number;
|
|
207
223
|
private readonly running = new Map<string, RunningTrigger>();
|
|
208
224
|
/** Pushes refused for want of a slot, waiting for the next tick to retry them (see `queuePush`). */
|
|
209
225
|
private readonly pending = new Map<string, { trigger: Trigger; delivery: TriggerDelivery }>();
|
|
210
226
|
/** Every sub-agent this process starts takes a slot from here, loop runs included. */
|
|
211
227
|
readonly slots: SubagentSlots;
|
|
212
228
|
private readonly budget: () => { spent: number; cap: number; over: boolean };
|
|
229
|
+
private readonly inbox: Pick<Inbox, "appendOnce"> | undefined;
|
|
213
230
|
pollIntervalSecs: number;
|
|
214
231
|
lastCheckAt = 0;
|
|
215
232
|
/** Bounded, display-only status of the latest periodic check. */
|
|
216
|
-
lastPoll:
|
|
233
|
+
lastPoll: LastPoll | undefined;
|
|
234
|
+
/** The same, per project: the host checks every project's rules, and a window shows its own. */
|
|
235
|
+
readonly lastPolls = new Map<string, LastPoll>();
|
|
217
236
|
dedupedCount = 0;
|
|
218
237
|
cycleSuppressedCount = 0;
|
|
238
|
+
/** The `stat`s of rules that wait for a path (src/path-precheck.ts): one in flight per rule. */
|
|
239
|
+
private readonly looks: PathLooks;
|
|
240
|
+
/** Each waiting rule's last look in this process and when it was made; the audit row is written on a change only. */
|
|
241
|
+
private readonly lastLooks = new Map<string, { look: Look; at: number }>();
|
|
219
242
|
|
|
220
243
|
constructor(opts: TriggerRuntimeOptions) {
|
|
221
244
|
this.store = opts.store;
|
|
@@ -227,14 +250,12 @@ export class TriggerRuntime {
|
|
|
227
250
|
this.pollIntervalSecs = opts.pollIntervalSecs ?? DEFAULT_TRIGGER_POLL_INTERVAL_SECS;
|
|
228
251
|
this.dedup = new DedupWindow(undefined, opts.dedupFile);
|
|
229
252
|
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
253
|
this.hop = opts.hop ?? 0;
|
|
234
254
|
this.runTimeoutMs = opts.runTimeoutMs ?? DEFAULT_TRIGGER_RUN_TIMEOUT_MS;
|
|
235
|
-
this.deferredTakeoverMs = opts.deferredTakeoverMs ?? DEFAULT_DEFERRED_TAKEOVER_MS;
|
|
236
255
|
this.slots = opts.slots ?? new SubagentSlots(opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_CHECKS);
|
|
237
256
|
this.budget = opts.budget ?? (() => ({ spent: 0, cap: 0, over: false }));
|
|
257
|
+
this.inbox = opts.inbox;
|
|
258
|
+
this.looks = new PathLooks({ stat: opts.stat });
|
|
238
259
|
// Audit writes are best-effort — a failed write never fails a trigger; say so once per distinct error.
|
|
239
260
|
let lastReported: string | undefined;
|
|
240
261
|
this.store.onPersistenceError ??= (message) => {
|
|
@@ -279,60 +300,30 @@ export class TriggerRuntime {
|
|
|
279
300
|
this.abortAll();
|
|
280
301
|
}
|
|
281
302
|
|
|
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
303
|
/**
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
* `ownIfUnowned` is passed straight to `ownsRule`: it only adds the rules nobody owns.
|
|
304
|
+
* The enabled rules that govern `cwd`. A rule created at `~/proj` still governs a push received
|
|
305
|
+
* at `~/proj/src` or through a symlink to it: comparing the cwd strings silently diverted those
|
|
306
|
+
* promotions to the inbox.
|
|
317
307
|
*/
|
|
318
|
-
private rulesFor(cwd: string | undefined
|
|
308
|
+
private rulesFor(cwd: string | undefined): DynamicTriggerRule[] {
|
|
319
309
|
const all = this.store.load().filter((r) => r.enabled);
|
|
320
|
-
|
|
321
|
-
return { applicable, owned: applicable.filter((r) => this.ownsRule(r, ownIfUnowned)) };
|
|
310
|
+
return cwd ? all.filter((r) => withinProject(r.cwd, cwd)) : all;
|
|
322
311
|
}
|
|
323
312
|
|
|
324
313
|
/**
|
|
325
|
-
* Scheduler tick
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
314
|
+
* Scheduler tick of the host, the one process that checks rules: one periodic check per project,
|
|
315
|
+
* at most once per poll interval.
|
|
316
|
+
*
|
|
317
|
+
* The poll ledger is shared with pi-loops 0.22 windows during an upgrade. Those claim
|
|
318
|
+
* `<host>:<cwd>` for a project's unowned rules and `<host>:<cwd>#<session>` for the rules of a
|
|
319
|
+
* session they have open, so this claims the same keys: a project another process has just
|
|
320
|
+
* checked is skipped, and so are the rules of a session an old window checked itself.
|
|
329
321
|
*/
|
|
330
|
-
async tick(now: number
|
|
331
|
-
// Before raising new work: the pushes this process refused while it was busy.
|
|
332
|
-
// backlog, not the leader's, so leadership does not gate them.
|
|
322
|
+
async tick(now: number): Promise<void> {
|
|
323
|
+
// Before raising new work: the pushes this process refused while it was busy.
|
|
333
324
|
this.retryPending();
|
|
334
|
-
const host =
|
|
335
|
-
const rules = this.store.load().filter((r) => r.enabled &&
|
|
325
|
+
const host = os.hostname();
|
|
326
|
+
const rules = this.store.load().filter((r) => r.enabled && !this.inBackoff(r, now));
|
|
336
327
|
if (!rules.length) return;
|
|
337
328
|
const byCwd = new Map<string, DynamicTriggerRule[]>();
|
|
338
329
|
for (const r of rules) byCwd.set(r.cwd, [...(byCwd.get(r.cwd) ?? []), r]);
|
|
@@ -348,30 +339,75 @@ export class TriggerRuntime {
|
|
|
348
339
|
continue;
|
|
349
340
|
}
|
|
350
341
|
if ([...this.running.values()].some((r) => r.cwd === cwd && r.sourceLabel === "local:dynamic")) continue; // previous check still active
|
|
351
|
-
const
|
|
342
|
+
const interval = this.pollIntervalSecs * 1000;
|
|
343
|
+
if (!(await this.ledger.claim(`${host}:${cwd}`, now, interval))) continue;
|
|
344
|
+
const claimed: DynamicTriggerRule[] = [];
|
|
345
|
+
const bySession = new Map<string, DynamicTriggerRule[]>();
|
|
352
346
|
for (const r of group) {
|
|
353
|
-
const
|
|
354
|
-
|
|
347
|
+
const sid = r.createdBy?.sessionId;
|
|
348
|
+
if (sid) bySession.set(sid, [...(bySession.get(sid) ?? []), r]);
|
|
349
|
+
else claimed.push(r);
|
|
355
350
|
}
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
if (await this.ledger.claim(`${host}:${cwd}${slot ? `#${slot}` : ""}`, now, this.pollIntervalSecs * 1000)) claimed.push(...owned);
|
|
351
|
+
for (const [sid, own] of bySession) {
|
|
352
|
+
if (await this.ledger.claim(`${host}:${cwd}#${sid}`, now, interval)) claimed.push(...own);
|
|
359
353
|
}
|
|
360
354
|
if (!claimed.length) continue;
|
|
361
355
|
this.lastCheckAt = now;
|
|
356
|
+
// After the claim, so a rule that waits for a path is looked at once per poll, as a model
|
|
357
|
+
// check would be. A path that is absent keeps its rule out of the prompt; an `unknown` look
|
|
358
|
+
// (no permission, a hung mount) asks the model, as before there was a pre-check.
|
|
359
|
+
const looks = await Promise.all(claimed.map((r) => this.lookFor(r)));
|
|
360
|
+
this.noteLooks(cwd, claimed, looks, now);
|
|
361
|
+
const due = claimed.filter((_, i) => looks[i] !== "absent");
|
|
362
|
+
if (!due.length) continue;
|
|
363
|
+
const prechecked = claimed.filter((_, i) => looks[i] === "present").map((r) => r.id);
|
|
362
364
|
// `handle` is documented not to reject; the catch is what makes that true rather than
|
|
363
365
|
// merely intended, since nothing above this would survive one.
|
|
364
|
-
void this.handle(buildPeriodicCheckTrigger(cwd,
|
|
366
|
+
void this.handle(buildPeriodicCheckTrigger(cwd, due.length, new Date(now), String(process.pid)), "sub_agent", due, prechecked.length ? { prechecked } : undefined).catch((err: any) =>
|
|
365
367
|
this.log(`check failed: ${err?.message ?? err}`),
|
|
366
368
|
);
|
|
367
369
|
}
|
|
368
370
|
}
|
|
369
371
|
|
|
372
|
+
/** A waiting rule's look, or undefined for a rule the model checks as it always did. */
|
|
373
|
+
private lookFor(rule: DynamicTriggerRule): Promise<Look | undefined> {
|
|
374
|
+
const w = watchOf(rule);
|
|
375
|
+
return w ? this.looks.look(rule.id, w) : Promise.resolve(undefined);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* One `trigger` audit row per waiting rule on a change only: `waiting` at the first look this
|
|
380
|
+
* process makes and when the path goes away, `appeared` when it comes back. A rule whose path stays
|
|
381
|
+
* absent writes nothing more, which is the point: no `running` row and no model call either.
|
|
382
|
+
*/
|
|
383
|
+
private noteLooks(cwd: string, rules: DynamicTriggerRule[], looks: Array<Look | undefined>, now: number): void {
|
|
384
|
+
rules.forEach((rule, i) => {
|
|
385
|
+
const look = looks[i];
|
|
386
|
+
const w = watchOf(rule);
|
|
387
|
+
if (!look || !w) return;
|
|
388
|
+
const prev = this.lastLooks.get(rule.id)?.look;
|
|
389
|
+
this.lastLooks.set(rule.id, { look, at: now });
|
|
390
|
+
const row = (state: string, summary: string) =>
|
|
391
|
+
this.store.appendAudit({ cwd, type: "trigger", traceId: newTraceId(), state, sourceLabel: "local:dynamic", eventLabel: rule.id, summary, details: { rule_id: rule.id, path: w.raw, resolved: w.resolved } });
|
|
392
|
+
if (look === "absent" && prev !== "absent") row("waiting", `no model call until ${w.raw} exists`);
|
|
393
|
+
else if (look === "present" && prev === "absent") row("appeared", `${w.raw} exists; the model checks the rule`);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* What the page's rule row says about a rule that waits for a path: its last look here, and when
|
|
399
|
+
* the next one is. Polls ride the scheduler's 30-second tick, so no look comes sooner than that.
|
|
400
|
+
*/
|
|
401
|
+
ruleLook(ruleId: string): { look: Look; nextAt: number } | undefined {
|
|
402
|
+
const last = this.lastLooks.get(ruleId);
|
|
403
|
+
return last && { look: last.look, nextAt: last.at + Math.max(this.pollIntervalSecs * 1000, POLL_TICK_MS) };
|
|
404
|
+
}
|
|
405
|
+
|
|
370
406
|
/**
|
|
371
407
|
* Admit one trigger: dedup, audit, deliver. Resolves when the delivery has finished and
|
|
372
408
|
* never rejects: persistence or runner failures are audited (best effort) and logged.
|
|
373
409
|
*/
|
|
374
|
-
async handle(trigger: Trigger, delivery: TriggerDelivery, rules?: DynamicTriggerRule[]): Promise<TriggerOutcome | undefined> {
|
|
410
|
+
async handle(trigger: Trigger, delivery: TriggerDelivery, rules?: DynamicTriggerRule[], opts?: { prechecked?: string[] }): Promise<TriggerOutcome | undefined> {
|
|
375
411
|
// Sub-agents register no notification hooks and run no dynamic checker, so only the
|
|
376
412
|
// interactive process (hop 0) ever handles a trigger. Anything reaching a deeper hop is a
|
|
377
413
|
// cycle and is suppressed, audited as cycle-suppressed.
|
|
@@ -390,7 +426,7 @@ export class TriggerRuntime {
|
|
|
390
426
|
const outcomes = await Promise.all(cwds.map((cwd, i) => this.admit({ ...trigger, cwd, traceId: i ? newTraceId() : trigger.traceId }, delivery)));
|
|
391
427
|
return outcomes.find(Boolean);
|
|
392
428
|
}
|
|
393
|
-
return await this.admit(trigger, delivery, rules);
|
|
429
|
+
return await this.admit(trigger, delivery, rules, opts);
|
|
394
430
|
} catch (err: any) {
|
|
395
431
|
const message = err?.message ?? String(err);
|
|
396
432
|
this.running.delete(trigger.traceId);
|
|
@@ -400,7 +436,7 @@ export class TriggerRuntime {
|
|
|
400
436
|
}
|
|
401
437
|
}
|
|
402
438
|
|
|
403
|
-
private async admit(trigger: Trigger, delivery: TriggerDelivery, rules?: DynamicTriggerRule[]): Promise<TriggerOutcome | undefined> {
|
|
439
|
+
private async admit(trigger: Trigger, delivery: TriggerDelivery, rules?: DynamicTriggerRule[], opts?: { prechecked?: string[] }): Promise<TriggerOutcome | undefined> {
|
|
404
440
|
// A sub-agent costs money and a process slot. Both bounds are checked before the dedup claim,
|
|
405
441
|
// so a refused trigger is never marked handled — a periodic check is simply raised again by the
|
|
406
442
|
// next poll, and a push (an event that happened once, which no server repeats) is held below.
|
|
@@ -440,24 +476,10 @@ export class TriggerRuntime {
|
|
|
440
476
|
try {
|
|
441
477
|
let prev: Awaited<ReturnType<DedupWindow["check"]>>;
|
|
442
478
|
if (trigger.source.kind === "mcp" && delivery === "sub_agent") {
|
|
443
|
-
// A push evaluated against dynamic rules: by the
|
|
444
|
-
// evaluates its own registry), and once per ownership slot machine-wide.
|
|
479
|
+
// A push evaluated against dynamic rules: by the host alone, once per project machine-wide.
|
|
445
480
|
const cwd = trigger.cwd ?? this.getSession().cwd;
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
if (scope.applicable.length && !scope.owned.length) {
|
|
449
|
-
// The owner may not have this MCP server connected at all — servers authenticate per
|
|
450
|
-
// process — and a push nobody handles is simply lost. Record the hand-off, then take the
|
|
451
|
-
// push back if the owner has not claimed the slot below inside the takeover window; the
|
|
452
|
-
// claim is machine-wide, so at most one process ever evaluates it.
|
|
453
|
-
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) } });
|
|
454
|
-
await new Promise((r) => setTimeout(r, this.deferredTakeoverMs));
|
|
455
|
-
rules = scope.applicable;
|
|
456
|
-
}
|
|
457
|
-
prev = await this.dedup.check(this.pushClaimKey(trigger, cwd, rules), trigger.traceId, this.now(), trigger.replacementPolicy);
|
|
458
|
-
if (!prev && !scope.owned.length && scope.applicable.length) {
|
|
459
|
-
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) } });
|
|
460
|
-
}
|
|
481
|
+
rules = this.rulesFor(cwd);
|
|
482
|
+
prev = await this.dedup.check(`${trigger.idempotencyKey}@${os.hostname()}:${realpathish(cwd)}`, trigger.traceId, this.now(), trigger.replacementPolicy);
|
|
461
483
|
} else if (trigger.source.kind === "mcp") {
|
|
462
484
|
// A push injected into the chat: every window that has the server reacts (every
|
|
463
485
|
// session), so the dedup window is this process's own.
|
|
@@ -478,7 +500,7 @@ export class TriggerRuntime {
|
|
|
478
500
|
// runs the finally at once, which would release the slot the moment the sub-agent started.
|
|
479
501
|
if (delivery === "inject_summary") return await this.deliverInjectSummary(trigger);
|
|
480
502
|
if (delivery === "inject_and_run") return await this.deliverInjectAndRun(trigger);
|
|
481
|
-
return await this.deliverSubAgent(trigger, rules);
|
|
503
|
+
return await this.deliverSubAgent(trigger, rules, opts?.prechecked);
|
|
482
504
|
} finally {
|
|
483
505
|
// Held for the whole delivery, `deliverSubAgent` included, and released on every path out of
|
|
484
506
|
// it — a torn dedup file, a runner that rejects, an abort — or the pool leaks a slot.
|
|
@@ -578,25 +600,14 @@ export class TriggerRuntime {
|
|
|
578
600
|
this.log(`${backing.length} trigger rule(s) failed ${worst} checks in a row; polling them with a widening gap`);
|
|
579
601
|
}
|
|
580
602
|
|
|
581
|
-
/**
|
|
582
|
-
* The machine-wide claim a pushed evaluation takes. Keyed by the project (realpath, so two
|
|
583
|
-
* windows reaching it by different paths agree) and by the ownership slots of the rules being
|
|
584
|
-
* evaluated, so windows checking *different* rules of one project both react while a deferred
|
|
585
|
-
* push and its owner still contend for the same key.
|
|
586
|
-
*/
|
|
587
|
-
private pushClaimKey(trigger: Trigger, cwd: string, rules: DynamicTriggerRule[]): string {
|
|
588
|
-
const slots = [...new Set(rules.map((r) => this.slotOf(r)))].sort().join(",");
|
|
589
|
-
return `${trigger.idempotencyKey}@${this.self?.host ?? os.hostname()}:${realpathish(cwd)}${slots ? `#${slots}` : ""}`;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
603
|
private async deliverInjectSummary(trigger: Trigger): Promise<TriggerOutcome> {
|
|
593
604
|
const start = this.now();
|
|
594
605
|
const summary = trigger.payloadSummary ?? "";
|
|
595
606
|
let promoted = false;
|
|
596
607
|
if (summary) {
|
|
597
608
|
const target = (await this.hooks.onPromote?.(promotionBody(trigger, summary), trigger)) ?? "chat";
|
|
598
|
-
promoted = target
|
|
599
|
-
this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger_promotion", traceId: trigger.traceId, state: target === "
|
|
609
|
+
promoted = target !== "inbox";
|
|
610
|
+
this.store.appendAudit({ cwd: trigger.cwd ?? this.getSession().cwd, type: "trigger_promotion", traceId: trigger.traceId, state: target === "inbox" ? "redirected" : "promoted", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary, details: { prefix_injected: true, delivery: "inject_summary", to: target, ...envelopeOf(trigger) } });
|
|
600
611
|
}
|
|
601
612
|
// `over_budget` on a completed row is the audit's answer to "everything else stopped today —
|
|
602
613
|
// why did this run": because it cost nothing.
|
|
@@ -612,7 +623,7 @@ export class TriggerRuntime {
|
|
|
612
623
|
const prompt = `[Trigger ${trigger.traceId}] ${trigger.payloadSummary ?? `${trigger.sourceLabel} fired: ${trigger.eventLabel}`}`;
|
|
613
624
|
const target = (await this.hooks.onInjectAndRun?.(prompt, trigger)) ?? "chat";
|
|
614
625
|
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) } });
|
|
615
|
-
const outcome: TriggerOutcome = { trigger, delivery: "inject_and_run", ok: true, matchedRules: [], summary: trigger.payloadSummary ?? "", durationMs: this.now() - start, cost: 0, promoted: target
|
|
626
|
+
const outcome: TriggerOutcome = { trigger, delivery: "inject_and_run", ok: true, matchedRules: [], summary: trigger.payloadSummary ?? "", durationMs: this.now() - start, cost: 0, promoted: target !== "inbox" };
|
|
616
627
|
this.hooks.onFinished?.(outcome);
|
|
617
628
|
return outcome;
|
|
618
629
|
}
|
|
@@ -626,13 +637,13 @@ export class TriggerRuntime {
|
|
|
626
637
|
return trigger.cwd ?? this.getSession().cwd;
|
|
627
638
|
}
|
|
628
639
|
|
|
629
|
-
private async deliverSubAgent(trigger: Trigger, evaluate?: DynamicTriggerRule[]): Promise<TriggerOutcome> {
|
|
640
|
+
private async deliverSubAgent(trigger: Trigger, evaluate?: DynamicTriggerRule[], prechecked?: string[]): Promise<TriggerOutcome> {
|
|
630
641
|
const start = this.now();
|
|
631
642
|
const session = this.getSession();
|
|
632
643
|
const cwd = this.cwdOf(trigger);
|
|
633
644
|
// `evaluate` is what the caller already resolved (the tick's claimed rules, a taken-over
|
|
634
645
|
// push); otherwise this is a direct handle() and we take the rules of this project we own.
|
|
635
|
-
const rules = evaluate ?? this.rulesFor(trigger.cwd
|
|
646
|
+
const rules = evaluate ?? this.rulesFor(trigger.cwd);
|
|
636
647
|
if (!rules.length) {
|
|
637
648
|
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) } });
|
|
638
649
|
const outcome: TriggerOutcome = { trigger, delivery: "sub_agent", ok: true, matchedRules: [], summary: "no enabled dynamic trigger rules", durationMs: 0, cost: 0, promoted: false };
|
|
@@ -644,7 +655,7 @@ export class TriggerRuntime {
|
|
|
644
655
|
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
|
|
645
656
|
this.running.set(trigger.traceId, running);
|
|
646
657
|
this.hooks.onStarted?.(running);
|
|
647
|
-
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) } });
|
|
658
|
+
this.store.appendAudit({ cwd, type: "trigger_result", traceId: trigger.traceId, state: "running", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, details: { rule_count: rules.length, cwd, ...(prechecked?.length ? { prechecked } : {}), ...envelopeOf(trigger) } });
|
|
648
659
|
|
|
649
660
|
// The check runs with the model the rules were created under, not whatever the process that
|
|
650
661
|
// happens to own the timer is using. Both come from the first rule that recorded a model,
|
|
@@ -678,6 +689,23 @@ export class TriggerRuntime {
|
|
|
678
689
|
if (matchedRules.length) await this.store.markFired(matchedRules.map((r) => r.id));
|
|
679
690
|
const quiet = result.ok && matchedRules.length === 0;
|
|
680
691
|
const state = ctrl.signal.aborted ? "aborted" : result.ok ? "completed" : "failed";
|
|
692
|
+
// Every match is a finding, before any promotion and before the result's row, which records what
|
|
693
|
+
// was decided (`details.inbox`): the promotion's inbox fallback and the row's dead letter read it
|
|
694
|
+
// and file nothing more (checkDecidedFinding). The text is what the check said, unedited. It is
|
|
695
|
+
// news: what a check says comes from outside, and `· waits:` in it must not make it a decision.
|
|
696
|
+
// A repeating rule that says again what its latest new finding says files nothing.
|
|
697
|
+
let inboxDecision: "filed" | "repeat" | undefined;
|
|
698
|
+
if (result.ok && matchedRules.length && this.inbox) {
|
|
699
|
+
const source = `trigger:${trigger.sourceLabel}`;
|
|
700
|
+
for (const [forSession, group] of byCreator(matchedRules)) {
|
|
701
|
+
try {
|
|
702
|
+
const there = await this.inbox.appendOnce({ source, text: summary, run_id: trigger.traceId, job_id: group[0].id, cwd, session_id: forSession }, { run_id: trigger.traceId, source, session_id: forSession }, { unlessRepeat: true });
|
|
703
|
+
inboxDecision = there.run_id === trigger.traceId ? "filed" : (inboxDecision ?? "repeat");
|
|
704
|
+
} catch (err: any) {
|
|
705
|
+
this.log(`trigger ${trigger.traceId.slice(0, 8)}: inbox append failed: ${err?.message ?? err}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
681
709
|
this.store.appendAudit({
|
|
682
710
|
cwd,
|
|
683
711
|
type: "trigger_result",
|
|
@@ -686,7 +714,9 @@ export class TriggerRuntime {
|
|
|
686
714
|
sourceLabel: trigger.sourceLabel,
|
|
687
715
|
eventLabel: trigger.eventLabel,
|
|
688
716
|
summary: result.ok ? summary || NO_MATCH_SENTINEL : result.errorMessage,
|
|
689
|
-
|
|
717
|
+
// `matched_rule_ids` are this project's rules the check named; `named_rule_ids` is every id it
|
|
718
|
+
// wrote, a made-up one or another project's included, which only `/triggers audit` shows.
|
|
719
|
+
details: { delivery: "sub_agent", matched_rule_ids: matchedRules.map((r) => r.id), named_rule_ids: matchedIds, quiet, cost_usd: result.usage.cost, exit_code: result.exitCode, session_file: result.sessionFile, ...(inboxDecision ? { inbox: inboxDecision } : {}), ...envelopeOf(trigger) },
|
|
690
720
|
});
|
|
691
721
|
// An abort is the user's decision about this run, not evidence that the rule's check is broken,
|
|
692
722
|
// so it neither counts against the rule nor clears what earlier failures recorded.
|
|
@@ -695,13 +725,20 @@ export class TriggerRuntime {
|
|
|
695
725
|
let promoted = false;
|
|
696
726
|
const promoteRules = matchedRules.filter((r) => r.promoteToChat);
|
|
697
727
|
if (result.ok && promoteRules.length) {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
728
|
+
// One check covers every rule of the project, and two conversations in one repo each get the
|
|
729
|
+
// result of their own rules: one promotion per creating conversation.
|
|
730
|
+
for (const [forSession, group] of byCreator(promoteRules)) {
|
|
731
|
+
const target = (await this.hooks.onPromote?.(promotionSummaryBody(trigger, summary), trigger, forSession)) ?? "chat";
|
|
732
|
+
promoted ||= target !== "inbox";
|
|
733
|
+
this.store.appendAudit({ cwd, type: "trigger_promotion", traceId: trigger.traceId, state: target === "inbox" ? "redirected" : "promoted", sourceLabel: trigger.sourceLabel, eventLabel: trigger.eventLabel, summary, details: { prefix_injected: true, rule_ids: group.map((r) => r.id), to: target, template_name: "default", ...envelopeOf(trigger) } });
|
|
734
|
+
}
|
|
701
735
|
} else if (result.ok && matchedRules.length) {
|
|
702
736
|
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) } });
|
|
703
737
|
}
|
|
704
|
-
if (trigger.sourceLabel === "local:dynamic")
|
|
738
|
+
if (trigger.sourceLabel === "local:dynamic") {
|
|
739
|
+
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) };
|
|
740
|
+
this.lastPolls.set(cwd, this.lastPoll);
|
|
741
|
+
}
|
|
705
742
|
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 };
|
|
706
743
|
this.hooks.onFinished?.(outcome);
|
|
707
744
|
return outcome;
|
package/src/triggers.ts
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
* reports the matched `dyn-…` ids, which the runtime marks fired.
|
|
7
7
|
*/
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
9
10
|
import * as path from "node:path";
|
|
10
11
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
11
12
|
import { clampFuture, stamp } from "./schedule.ts";
|
|
12
13
|
import { withFileLock, writeFileAtomic } from "./lock.ts";
|
|
13
14
|
import { capRedacted, previewRedacted } from "./redact.ts";
|
|
15
|
+
import { type PathWatch, pathOnlyCondition } from "./path-precheck.ts";
|
|
14
16
|
|
|
15
17
|
export const DEFAULT_TRIGGER_POLL_INTERVAL_SECS = 10 * 60;
|
|
16
18
|
export const DEDUP_WINDOW_MS = 5 * 60_000;
|
|
@@ -40,6 +42,12 @@ export interface DynamicTriggerRule {
|
|
|
40
42
|
consecutiveFailures?: number;
|
|
41
43
|
/** When the last failing check ran: the backoff is measured from here, so a rule that has never been checked is never held back. */
|
|
42
44
|
lastCheckFailedAt?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Set when the condition is only "<path> exists / appears" (src/path-precheck.ts): each poll
|
|
47
|
+
* `stat`s the path first, and no model is asked while it is absent. Recorded at creation only;
|
|
48
|
+
* a rule without it is model-checked as before.
|
|
49
|
+
*/
|
|
50
|
+
precheck?: PathWatch;
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
export type SourceKind = "local" | "mcp";
|
|
@@ -112,7 +120,8 @@ export function parseTriggerRule(spec: string): ParsedTriggerRule {
|
|
|
112
120
|
return { condition, action };
|
|
113
121
|
}
|
|
114
122
|
|
|
115
|
-
|
|
123
|
+
/** A condition as the store keeps it: no leading "when" or "if", so a row that says `when …` says it once. */
|
|
124
|
+
export function cleanCondition(raw: string): string {
|
|
116
125
|
let s = raw.trim();
|
|
117
126
|
if (s.startsWith(ZH_WHEN)) s = s.slice(ZH_WHEN.length).trim();
|
|
118
127
|
if (s.startsWith(ZH_IF)) s = s.slice(ZH_IF.length).trim();
|
|
@@ -215,7 +224,7 @@ interface RulesFile {
|
|
|
215
224
|
rules: DynamicTriggerRule[];
|
|
216
225
|
}
|
|
217
226
|
|
|
218
|
-
export type AuditType = "trigger" | "trigger_result" | "trigger_promotion" | "cron_control_plane";
|
|
227
|
+
export type AuditType = "trigger" | "trigger_result" | "trigger_promotion" | "cron_control_plane" | "request";
|
|
219
228
|
|
|
220
229
|
export interface AuditRecord {
|
|
221
230
|
ts: string;
|
|
@@ -258,7 +267,12 @@ export class TriggerStore {
|
|
|
258
267
|
throw err;
|
|
259
268
|
}
|
|
260
269
|
if (!text.trim()) return [];
|
|
261
|
-
|
|
270
|
+
let parsed: RulesFile;
|
|
271
|
+
try {
|
|
272
|
+
parsed = JSON.parse(text) as RulesFile;
|
|
273
|
+
} catch (err: any) {
|
|
274
|
+
throw new Error(`${this.rulesFile}: ${err?.message ?? err}`);
|
|
275
|
+
}
|
|
262
276
|
if (!Array.isArray(parsed?.rules)) throw new Error(`${this.rulesFile}: missing "rules" array`);
|
|
263
277
|
// The pre-0.19.0 host stamp, dropped the way jobs.json drops it.
|
|
264
278
|
for (const r of parsed.rules) delete (r as { host?: unknown }).host;
|
|
@@ -291,7 +305,13 @@ export class TriggerStore {
|
|
|
291
305
|
thinking: input.thinking,
|
|
292
306
|
createdBy: { sessionId: input.sessionId },
|
|
293
307
|
};
|
|
294
|
-
|
|
308
|
+
// Here, in the process that creates the rule: `~` is the creator's home, which the background
|
|
309
|
+
// process started by a service manager may not share.
|
|
310
|
+
const only = pathOnlyCondition(condition, { cwd: input.cwd, home: os.homedir() });
|
|
311
|
+
if ("watch" in only) rule.precheck = only.watch;
|
|
312
|
+
await this.mutate((rules) => {
|
|
313
|
+
rules.push(rule);
|
|
314
|
+
});
|
|
295
315
|
return rule;
|
|
296
316
|
}
|
|
297
317
|
|
|
@@ -406,6 +426,16 @@ export class TriggerStore {
|
|
|
406
426
|
}
|
|
407
427
|
}
|
|
408
428
|
|
|
429
|
+
/**
|
|
430
|
+
* Whether the rule check of run `traceId` made its inbox decision: it filed the result, or skipped
|
|
431
|
+
* it as a repeat of its rule's latest new finding (`details.inbox` on its `trigger_result` row). The
|
|
432
|
+
* promotion's inbox fallback and the row's dead letter then file nothing: the check's decision for
|
|
433
|
+
* the run stands, whatever words the promotion wrapped the result in.
|
|
434
|
+
*/
|
|
435
|
+
export function checkDecidedFinding(audit: Pick<TriggerStore, "listAudit">, traceId: string): boolean {
|
|
436
|
+
return audit.listAudit(1, (r) => r.type === "trigger_result" && r.traceId === traceId && (r.details?.inbox === "filed" || r.details?.inbox === "repeat")).length > 0;
|
|
437
|
+
}
|
|
438
|
+
|
|
409
439
|
export const AUDIT_USAGE = "usage: /triggers audit [N] [--all] (N is the number of rows, default 10)";
|
|
410
440
|
|
|
411
441
|
/**
|
|
@@ -437,9 +467,11 @@ export function auditArgs(rest: string): { limit: number; all: boolean } | { usa
|
|
|
437
467
|
* on_control_plane_prompt hook configured"); a UI-less interactive process is refused; an
|
|
438
468
|
* interactive UI gets to ask. Returns the denial text, or undefined when the caller should ask.
|
|
439
469
|
*/
|
|
440
|
-
export function controlPlanePreflight(proc: { hop: number; hasUI: boolean }, reason: string): string | undefined {
|
|
441
|
-
|
|
442
|
-
if (
|
|
470
|
+
export function controlPlanePreflight(proc: { hop: number; hasUI: boolean; held?: boolean }, reason: string): string | undefined {
|
|
471
|
+
// A plain job's turn in a closed conversation: the person's own turn, run where they were not.
|
|
472
|
+
if (proc.held) return `${reason} requires user confirmation; this turn ran in the background with nobody to ask (fail-closed deny): report what you would have done, so it can be approved when the conversation is open`;
|
|
473
|
+
if (proc.hop > 0) return `${reason} requires user confirmation; sub-agents have no control-plane prompt channel (fail-closed deny): report what you would have done, so a person can do it`;
|
|
474
|
+
if (!proc.hasUI) return `${reason} requires interactive confirmation; a person can do it with the slash command`;
|
|
443
475
|
return undefined;
|
|
444
476
|
}
|
|
445
477
|
|