@alphacoder-v0/pi-loops 0.18.0 → 0.19.2
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 +52 -0
- package/README.md +6 -6
- package/README.zh-CN.md +6 -6
- package/docs/cli.md +6 -2
- package/docs/configuration.md +1 -1
- package/docs/design.md +20 -6
- package/docs/loops.md +25 -17
- package/docs/triggers.md +1 -1
- package/docs/troubleshooting.md +3 -2
- package/docs/web-ui-parity.md +7 -3
- package/package.json +1 -1
- package/skills/pi-loops/SKILL.md +2 -1
- package/src/archive.ts +5 -5
- package/src/args.ts +1 -8
- package/src/cli.ts +17 -5
- package/src/job-edit.ts +0 -3
- package/src/job-health.ts +6 -7
- package/src/job-owner.ts +29 -0
- package/src/pi-loops.ts +18 -25
- package/src/protocol.ts +2 -3
- package/src/scheduler.ts +13 -13
- package/src/session-head.ts +98 -0
- package/src/store.ts +7 -17
- package/src/tools.ts +14 -19
- package/src/trigger-runtime.ts +3 -5
- package/src/triggers.ts +3 -4
- package/src/web.mjs +35 -16
package/src/pi-loops.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { execFile } from "node:child_process";
|
|
|
37
37
|
import { createHash } from "node:crypto";
|
|
38
38
|
import { computeDue, computeNext, formatLocal, formatSchedule, localOffset, parseSchedule, stamp } from "./schedule.ts";
|
|
39
39
|
import { applyJobEdit } from "./job-edit.ts";
|
|
40
|
+
import { asleepNote, runsIn } from "./job-owner.ts";
|
|
40
41
|
import { AUTONOMY_LEVELS, type AutonomyLevel, INSTALL_ROOT, type Recipe, type UpdateResult, addLineFor, ensureExcluded, installDirFor, installFiles, installedRecipes, isRecipeName, listRecipes, MAX_SETUP_SHOWN, TRACKER_FILE, loadRecipe, packagedRecipesDir, parseAddWords, planInstall, playbookFiles, purgeInstall, readRecord, requireRecipeName, resolveRecipeRef, updateFiles } from "./recipe.ts";
|
|
41
42
|
import { LoopScheduler, type SessionSnapshot } from "./scheduler.ts";
|
|
42
43
|
import { MAX_PROMPT_BYTES, type LoopJob, type RunRecord, defaultLoopsDir, newId, resolveJobRef, sessionExists } from "./store.ts";
|
|
@@ -663,7 +664,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
663
664
|
if (n > 0) parts.push(`Inbox: ${n} new`);
|
|
664
665
|
// Machine-wide like the inbox count and the running list above it: the clock is one per host,
|
|
665
666
|
// and a loop failing in another checkout is still this machine's automation going quiet.
|
|
666
|
-
const failing = failingSummary(scheduler.store.load()
|
|
667
|
+
const failing = failingSummary(scheduler.store.load());
|
|
667
668
|
if (failing) parts.push(failing);
|
|
668
669
|
const running = [...scheduler.runningLabels(), ...triggers.runningList().map((r) => (r.sourceLabel === "local:dynamic" ? "trigger-check" : r.sourceLabel))];
|
|
669
670
|
if (running.length) parts.push(`running: ${running.join(", ")}`);
|
|
@@ -730,13 +731,9 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
730
731
|
|
|
731
732
|
function jobLines(jobs: LoopJob[]): string[] {
|
|
732
733
|
const now = Date.now();
|
|
733
|
-
const here = os.hostname();
|
|
734
734
|
return jobs.map((job, i) => {
|
|
735
|
-
// A
|
|
736
|
-
|
|
737
|
-
// catches a renamed machine or a rebuilt container, which needs no second machine at all.
|
|
738
|
-
const elsewhere = job.host && job.host !== here ? `[other host: ${job.host} — /cron set ${i + 1} --host here]` : undefined;
|
|
739
|
-
const next = job.enabled && !elsewhere
|
|
735
|
+
// A next run is shown where it will happen: a plain job of a session not open here has none.
|
|
736
|
+
const next = job.enabled && runsIn(job, session.sessionId)
|
|
740
737
|
? computeNext(
|
|
741
738
|
{
|
|
742
739
|
schedule: job.schedule,
|
|
@@ -746,9 +743,10 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
746
743
|
now,
|
|
747
744
|
)
|
|
748
745
|
: undefined;
|
|
749
|
-
const
|
|
746
|
+
const asleepMark = asleepNote(job, session.sessionId);
|
|
747
|
+
const dormant = asleepMark ? `[${asleepMark}]` : undefined;
|
|
750
748
|
const orphan = job.stateful && !fs.existsSync(job.cwd) ? "[orphan: cwd missing]" : undefined;
|
|
751
|
-
const marks = [job.stateful ? "[stateful]" : undefined, job.verify ? "[verify]" : undefined,
|
|
749
|
+
const marks = [job.stateful ? "[stateful]" : undefined, job.verify ? "[verify]" : undefined, dormant, orphan, job.running ? `running ${job.running.runId}` : undefined, job.catchUp ? undefined : "[no-catchup]"]
|
|
752
750
|
.filter(Boolean)
|
|
753
751
|
.join(" ");
|
|
754
752
|
const head = `${String(i + 1).padStart(2)}. ${job.id}${job.name ? ` "${job.name}"` : ""} ${job.enabled ? "enabled" : "disabled"} ${formatSchedule(job.schedule)}${marks ? ` ${marks}` : ""}`;
|
|
@@ -798,7 +796,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
798
796
|
" --verify: maker/checker — a second adversarial sub-agent reviews findings before they enter /inbox (--checker-model <provider/id> to use another model)",
|
|
799
797
|
" more flags: --name <n> --cwd <dir> --model <provider/id> --thinking <lvl> --tools a,b --timeout 20m --catchup|--no-catchup (default: loops catch up a missed tick, plain jobs do not)",
|
|
800
798
|
"/cron enable|disable|remove <n|id|name> /cron run <n|id|name> fire now",
|
|
801
|
-
"/cron set <n|id|name> [--model <p/id>|-] [--thinking <lvl>|-] [--timeout <dur>|-] [--name <n>|-]
|
|
799
|
+
"/cron set <n|id|name> [--model <p/id>|-] [--thinking <lvl>|-] [--timeout <dur>|-] [--name <n>|-] change what a job runs with (- = use the session's current)",
|
|
802
800
|
' also [--prompt "<text>"] [--schedule "<expr>"]: reword a loop or move it to another hour in place — the job keeps its id, and a stateful loop keeps its notes',
|
|
803
801
|
"/cron state <n|id|name> the loop's notes (state spine)",
|
|
804
802
|
"/cron runs [n|id|name] recent runs /cron trace [n|id|name] [k] [checker] k-th latest run's transcript (maker, or its checker)",
|
|
@@ -1067,7 +1065,6 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1067
1065
|
// it can be tested. Anything it refuses throws before the store is touched.
|
|
1068
1066
|
const applied = applyJobEdit(job, change, {
|
|
1069
1067
|
now: Date.now(),
|
|
1070
|
-
hostName: os.hostname(),
|
|
1071
1068
|
maxPromptBytes: MAX_PROMPT_BYTES,
|
|
1072
1069
|
checkName: checkJobName,
|
|
1073
1070
|
others: scheduler.store.load().filter((j) => j.id !== job.id),
|
|
@@ -1094,7 +1091,6 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1094
1091
|
` model: ${updated.model ?? "(the running session's current model)"}`,
|
|
1095
1092
|
` thinking: ${updated.thinking ?? "(the running session's current level)"}`,
|
|
1096
1093
|
` timeout: ${updated.timeoutMs ? `${Math.round(updated.timeoutMs / 1000)}s` : "default"}`,
|
|
1097
|
-
` host: ${updated.host ?? "(any machine)"}`,
|
|
1098
1094
|
]);
|
|
1099
1095
|
return;
|
|
1100
1096
|
}
|
|
@@ -1512,8 +1508,8 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1512
1508
|
/* ---------------------------------------------------------- /triggers */
|
|
1513
1509
|
|
|
1514
1510
|
// The menu names every subcommand; the arguments live in TRIGGERS_HELP below, which `/triggers
|
|
1515
|
-
// help` prints. This line used to *be* that help, so what it left out (`hooks`, `panel
|
|
1516
|
-
//
|
|
1511
|
+
// help` prints. This line used to *be* that help, so what it left out (`hooks`, `panel`)
|
|
1512
|
+
// was left out of the product.
|
|
1517
1513
|
const TRIGGERS_USAGE = "[status|rules|sources|hooks|enable|disable|remove|set|run|running|audit|abort|panel|help] — /triggers help for the arguments";
|
|
1518
1514
|
|
|
1519
1515
|
const TRIGGERS_HELP = [
|
|
@@ -1522,7 +1518,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1522
1518
|
"/triggers sources MCP push sources, the local crontab, the dynamic checker — and what each has seen (/triggers hooks is the same view)",
|
|
1523
1519
|
"/triggers enable <n|id> /triggers disable <n|id> also --all (this project) | --all-projects (the machine)",
|
|
1524
1520
|
"/triggers remove <n|id> also remove --all | remove --all-projects; /new-trigger creates one",
|
|
1525
|
-
"/triggers set <n|id> [--model <p/id>|-] [--thinking <lvl>|-] [--timeout <dur>|-]
|
|
1521
|
+
"/triggers set <n|id> [--model <p/id>|-] [--thinking <lvl>|-] [--timeout <dur>|-] what the action runs with (- = the session's current)",
|
|
1526
1522
|
"/triggers run <n|id> check one rule now — dedup, audit, sub-agent and promotion as on a poll",
|
|
1527
1523
|
"/triggers running actions in flight (dynamic checks and cron runs), and the sub-agent slots in use",
|
|
1528
1524
|
"/triggers audit [N] [--all] recent decisions, with each run's transcript path (default 10)",
|
|
@@ -1540,9 +1536,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1540
1536
|
const fired = r.firedAt ? `, fired_at=${r.firedAt}` : "";
|
|
1541
1537
|
const head = numbered ? `${String(i + 1).padStart(2)}. ` : " - ";
|
|
1542
1538
|
const other = r.createdBy?.sessionId && r.createdBy.sessionId !== session.sessionId ? ` (session ${r.createdBy.sessionId.slice(0, 8)})` : "";
|
|
1543
|
-
|
|
1544
|
-
const elsewhere = r.host && r.host !== os.hostname() ? ` [other host: ${r.host}]` : "";
|
|
1545
|
-
return `${head}${r.id} [${state}, ${fire}, ${out}${fired}]${elsewhere} when ${previewRedacted(r.condition, 80)} -> ${previewRedacted(r.action, 80)}${!sameProject(r.cwd, session.cwd) ? ` (${homeRel(r.cwd)})` : ""}${other}`;
|
|
1539
|
+
return `${head}${r.id} [${state}, ${fire}, ${out}${fired}] when ${previewRedacted(r.condition, 80)} -> ${previewRedacted(r.action, 80)}${!sameProject(r.cwd, session.cwd) ? ` (${homeRel(r.cwd)})` : ""}${other}`;
|
|
1546
1540
|
});
|
|
1547
1541
|
}
|
|
1548
1542
|
|
|
@@ -1671,7 +1665,7 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1671
1665
|
case "set": {
|
|
1672
1666
|
const change = parseSetArgs(rest);
|
|
1673
1667
|
if (change.name !== undefined) {
|
|
1674
|
-
ctx.ui.notify("rules have no name; /triggers set takes --model, --thinking
|
|
1668
|
+
ctx.ui.notify("rules have no name; /triggers set takes --model, --thinking and --timeout", "warning");
|
|
1675
1669
|
return;
|
|
1676
1670
|
}
|
|
1677
1671
|
const rule = pickRule(change.ref);
|
|
@@ -1680,14 +1674,12 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
1680
1674
|
if (change.model !== undefined) r.model = change.model ?? undefined;
|
|
1681
1675
|
if (change.thinking !== undefined) r.thinking = change.thinking ?? undefined;
|
|
1682
1676
|
if (change.timeoutMs !== undefined) r.timeoutMs = change.timeoutMs ?? undefined;
|
|
1683
|
-
if (change.host !== undefined) r.host = change.host === "here" ? os.hostname() : undefined;
|
|
1684
1677
|
});
|
|
1685
1678
|
if (!updated) return;
|
|
1686
1679
|
show(ctx, `updated trigger ${updated.id}`, [
|
|
1687
1680
|
` model: ${updated.model ?? "(the running session's current model)"}`,
|
|
1688
1681
|
` thinking: ${updated.thinking ?? "(the running session's current level)"}`,
|
|
1689
1682
|
` timeout: ${updated.timeoutMs ? `${Math.round(updated.timeoutMs / 1000)}s` : `default (${Math.round(triggers.runTimeoutMs / 1000)}s)`}`,
|
|
1690
|
-
` host: ${updated.host ?? "(any machine)"}`,
|
|
1691
1683
|
]);
|
|
1692
1684
|
return;
|
|
1693
1685
|
}
|
|
@@ -2471,11 +2463,12 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2471
2463
|
pruneLogs(dir);
|
|
2472
2464
|
// What was loaded is printed on every start; without a line here a session
|
|
2473
2465
|
// can begin with loops and rules the user has entirely forgotten about.
|
|
2474
|
-
|
|
2466
|
+
// "Active here" means it: a plain job of another session is asleep, not active.
|
|
2467
|
+
const myJobs = scheduler.store.load().filter((j) => sameProject(j.cwd, session.cwd) && j.enabled && runsIn(j, session.sessionId));
|
|
2475
2468
|
const myRules = triggers.store.load().filter((r) => sameProject(r.cwd, session.cwd) && r.enabled);
|
|
2476
2469
|
// What is scheduled says nothing about what is working: a loop that has failed every night
|
|
2477
2470
|
// since Tuesday is still counted as active, and this line is the only one a user reads.
|
|
2478
|
-
const failing = failingSummary(myJobs
|
|
2471
|
+
const failing = failingSummary(myJobs);
|
|
2479
2472
|
log.info(`session start: ${myJobs.length} enabled loop(s), ${myRules.length} enabled rule(s)${failing ? `, ${failing}` : ""} in ${session.cwd}`);
|
|
2480
2473
|
if (ctx.hasUI && (myJobs.length || myRules.length)) {
|
|
2481
2474
|
const next = myJobs
|
|
@@ -2542,14 +2535,14 @@ export default function piLoops(pi: ExtensionAPI) {
|
|
|
2542
2535
|
/** Whether this quitting pi should hand the clock to a background host. May throw on a damaged store. */
|
|
2543
2536
|
function handOffDecision(here: string, wasStarted: boolean): ReturnType<typeof shouldHandOff> | undefined {
|
|
2544
2537
|
if (!wasStarted) return undefined;
|
|
2545
|
-
const enabledRules = triggers.store.load().filter((r) => r.enabled
|
|
2538
|
+
const enabledRules = triggers.store.load().filter((r) => r.enabled).length;
|
|
2546
2539
|
return shouldHandOff({
|
|
2547
2540
|
auto: handsOffOnQuit(),
|
|
2548
2541
|
presence: scheduler.presenceList(),
|
|
2549
2542
|
selfPid: process.pid,
|
|
2550
2543
|
selfInstance: scheduler.self.instance,
|
|
2551
2544
|
hostName: here,
|
|
2552
|
-
enabledLoops: scheduler.store.load().filter((j) => j.enabled && j.stateful
|
|
2545
|
+
enabledLoops: scheduler.store.load().filter((j) => j.enabled && j.stateful).length,
|
|
2553
2546
|
enabledRules,
|
|
2554
2547
|
pushServers: hostPushWork(loadMcpConfigFiles({ dir, projectTrusted: false }).servers, enabledRules),
|
|
2555
2548
|
hostAlive: !!liveHost(dir),
|
package/src/protocol.ts
CHANGED
|
@@ -35,9 +35,8 @@ export function composeLoopPrompt(action: string, previousState: string | undefi
|
|
|
35
35
|
* The run time, and how to write one.
|
|
36
36
|
*
|
|
37
37
|
* The notes this prompt asks for hold watermarks — "everything up to here has been seen" — and
|
|
38
|
-
* a watermark is a time the model writes in whatever shape it likes.
|
|
39
|
-
*
|
|
40
|
-
* the `$HOME` (scheduler.ts, and `/cron set <ref> --host -` asks for exactly that), so run N can
|
|
38
|
+
* a watermark is a time the model writes in whatever shape it likes. A machine's clock is not a
|
|
39
|
+
* constant either: `TZ` gets set, a laptop travels, a container is rebuilt in UTC — so run N can
|
|
41
40
|
* write "checked up to 20:00" in Shanghai and run N+1 read it in New York.
|
|
42
41
|
*
|
|
43
42
|
* The stamp carries its offset; this asks for the same of anything the model writes back. It
|
package/src/scheduler.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The heartbeat. One pi process on the machine owns the timer at a time
|
|
3
|
-
* (leadership lives in scheduler
|
|
3
|
+
* (leadership lives in scheduler.json with a heartbeat); every other process
|
|
4
4
|
* with this extension stands by and takes over when the owner exits or dies.
|
|
5
5
|
* Loop jobs are therefore host-agnostic: close the pi that created a loop and
|
|
6
6
|
* any other open pi keeps it ticking; restart pi and missed ticks are caught up
|
|
@@ -20,7 +20,8 @@ import { type RunnerResult, type SubagentRunner, failedRun } from "./runner.ts";
|
|
|
20
20
|
import { previewRedacted, redact } from "./redact.ts";
|
|
21
21
|
import { type SubagentSlot, SubagentSlots } from "./slots.ts";
|
|
22
22
|
import { backoffWaitMs, computeDue, computeNext, formatLocal, formatLocalZoned, formatSchedule, isValidSchedule, stamp } from "./schedule.ts";
|
|
23
|
-
import { type CheckerRecord, JobStore, type LoopJob, type RunRecord,
|
|
23
|
+
import { type CheckerRecord, JobStore, type LoopJob, type RunRecord, newId } from "./store.ts";
|
|
24
|
+
import { ownerSession, runsIn } from "./job-owner.ts";
|
|
24
25
|
|
|
25
26
|
export const DEFAULT_TICK_MS = 30_000;
|
|
26
27
|
export const LEADER_STALE_MS = 90_000;
|
|
@@ -172,12 +173,12 @@ export class LoopScheduler {
|
|
|
172
173
|
this.store = new JobStore(opts.dir);
|
|
173
174
|
this.inbox = new Inbox(opts.dir);
|
|
174
175
|
// One leader per host: machines sharing a $HOME must not elect each other.
|
|
175
|
-
this.leaderFile = path.join(opts.dir,
|
|
176
|
+
this.leaderFile = path.join(opts.dir, "scheduler.json");
|
|
176
177
|
// Per host, like the leader record beside it, and for the same reason. Leadership is per
|
|
177
178
|
// host; two machines sharing a `$HOME` are both leaders, and a cron expression is matched
|
|
178
179
|
// against local time — so one file would be two machines writing different answers over each
|
|
179
180
|
// other, and a panel showing whichever wrote last.
|
|
180
|
-
this.nextRunsFile = path.join(opts.dir,
|
|
181
|
+
this.nextRunsFile = path.join(opts.dir, "next-runs.json");
|
|
181
182
|
this.leaderLock = path.join(opts.dir, "scheduler.lock");
|
|
182
183
|
this.getSession = opts.getSession;
|
|
183
184
|
this.hooks = opts.hooks ?? {};
|
|
@@ -276,7 +277,9 @@ export class LoopScheduler {
|
|
|
276
277
|
if (this.kind === "interactive" && rec.kind === "host") return true;
|
|
277
278
|
const age = now - Date.parse(rec.heartbeatAt);
|
|
278
279
|
if (Number.isNaN(age) || age > LEADER_STALE_MS) return true;
|
|
279
|
-
|
|
280
|
+
// One machine: a pid that is not alive here is not alive. (A record from a renamed or rebuilt
|
|
281
|
+
// box used to be untouchable until its heartbeat aged out; now it is taken over like any other.)
|
|
282
|
+
if (!pidAlive(rec.pid)) return true;
|
|
280
283
|
return false;
|
|
281
284
|
}
|
|
282
285
|
|
|
@@ -415,11 +418,9 @@ export class LoopScheduler {
|
|
|
415
418
|
this.log(`dead-session check failed: ${err?.message ?? err}`);
|
|
416
419
|
}
|
|
417
420
|
}
|
|
418
|
-
const host = os.hostname();
|
|
419
421
|
for (const job of jobs) {
|
|
420
422
|
if (!job.enabled) continue;
|
|
421
|
-
|
|
422
|
-
const owned = job.stateful ? leader : !!session.sessionId && job.sessionId === session.sessionId;
|
|
423
|
+
const owned = job.stateful ? leader : runsIn(job, session.sessionId);
|
|
423
424
|
if (!owned) continue;
|
|
424
425
|
// One unusable job must never stop the clock for the others: a schedule that cannot be
|
|
425
426
|
// evaluated disables that job and says why, instead of throwing out of the tick (pi
|
|
@@ -461,7 +462,7 @@ export class LoopScheduler {
|
|
|
461
462
|
if (!exists) return;
|
|
462
463
|
// Open somewhere (--no-session, --session-dir, another sessions root)? Then it is alive whatever the disk says.
|
|
463
464
|
const live = new Set(this.presence.list(this.now()).map((e) => e.sessionId).filter(Boolean));
|
|
464
|
-
const dead = jobs.filter((j) => j.enabled && !j.stateful && j.sessionId &&
|
|
465
|
+
const dead = jobs.filter((j) => j.enabled && !j.stateful && j.sessionId && !live.has(j.sessionId) && !exists(j.sessionId));
|
|
465
466
|
for (const job of dead) {
|
|
466
467
|
await this.store.update(job.id, (j) => {
|
|
467
468
|
j.enabled = false;
|
|
@@ -481,12 +482,8 @@ export class LoopScheduler {
|
|
|
481
482
|
*/
|
|
482
483
|
private writeNextRuns(jobs: LoopJob[], now: number): void {
|
|
483
484
|
const next: Record<string, string> = {};
|
|
484
|
-
const host = os.hostname();
|
|
485
485
|
for (const job of jobs) {
|
|
486
486
|
if (!job.enabled || !isValidSchedule(job.schedule)) continue;
|
|
487
|
-
// The same filter `dispatch` applies: this file is per host, and a next run for a job this
|
|
488
|
-
// machine never dispatches (shared $HOME) is a promise nothing here keeps.
|
|
489
|
-
if (job.host && job.host !== host) continue;
|
|
490
487
|
const at = computeNext({ schedule: job.schedule, createdAt: Date.parse(job.createdAt), lastFiredAt: job.lastFiredAt ? Date.parse(job.lastFiredAt) : undefined }, now);
|
|
491
488
|
if (at !== undefined) next[job.id] = stamp(at);
|
|
492
489
|
}
|
|
@@ -717,6 +714,9 @@ export class LoopScheduler {
|
|
|
717
714
|
// job the dead-session sweep parked is the marker `gc()` matches on, so running it once left
|
|
718
715
|
// a job nothing could fire and nothing could collect.
|
|
719
716
|
if (!job.enabled) return "is disabled (/cron enable it first)";
|
|
717
|
+
// The same reasoning for a job that is enabled but belongs to another session: its prompt is
|
|
718
|
+
// written for that conversation, and running it here would land it in this one.
|
|
719
|
+
if (!runsIn(job, this.getSession().sessionId)) return `belongs to session ${ownerSession(job)}, which is not open here — resume it, or /cron add the job again in this chat`;
|
|
720
720
|
// No `dueIso`: this run was asked for, so whatever slot the schedule owes is still owed.
|
|
721
721
|
if (!(await this.injectPlainJob(job, undefined, now))) return "has no chat to run in (the background host has no session)";
|
|
722
722
|
return true;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a session listing needs from a session file without reading the whole file: the header,
|
|
3
|
+
* the name someone gave it, the first thing the person said, and how many messages there are.
|
|
4
|
+
*
|
|
5
|
+
* `src/web.mjs` carries its own copy of this (it is one file with no imports, on purpose); a
|
|
6
|
+
* change here is a change there.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import type { DynamicTriggerRule } from "./triggers.ts";
|
|
10
|
+
import type { LoopJob } from "./store.ts";
|
|
11
|
+
|
|
12
|
+
export interface SessionHead {
|
|
13
|
+
id: string;
|
|
14
|
+
cwd: string;
|
|
15
|
+
/** The header's timestamp, as written. */
|
|
16
|
+
startedAt?: string;
|
|
17
|
+
/** `/name`, when someone set one. */
|
|
18
|
+
name?: string;
|
|
19
|
+
/** The first user message, as typed, bounded. */
|
|
20
|
+
first?: string;
|
|
21
|
+
messages: number;
|
|
22
|
+
/** The file was longer than the window read; `messages` is a floor. */
|
|
23
|
+
truncated: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const HEAD_BYTES = 64 * 1024;
|
|
27
|
+
|
|
28
|
+
export function readSessionHead(file: string, bytes: number = HEAD_BYTES): SessionHead | undefined {
|
|
29
|
+
let text: string;
|
|
30
|
+
try {
|
|
31
|
+
const fd = fs.openSync(file, "r");
|
|
32
|
+
try {
|
|
33
|
+
const buf = Buffer.alloc(bytes);
|
|
34
|
+
text = buf.toString("utf8", 0, fs.readSync(fd, buf, 0, bytes, 0));
|
|
35
|
+
} finally {
|
|
36
|
+
fs.closeSync(fd);
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const lines = text.split("\n");
|
|
42
|
+
let header: { type?: unknown; id?: unknown; cwd?: unknown; timestamp?: unknown };
|
|
43
|
+
try {
|
|
44
|
+
header = JSON.parse(lines[0] ?? "");
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
if (header?.type !== "session" || typeof header.id !== "string") return undefined;
|
|
49
|
+
const truncated = text.length >= bytes;
|
|
50
|
+
let name: string | undefined;
|
|
51
|
+
let first: string | undefined;
|
|
52
|
+
let messages = 0;
|
|
53
|
+
// The last line of a partial read is half a line; a file that fitted has no such line.
|
|
54
|
+
for (const line of lines.slice(1, truncated ? -1 : undefined)) {
|
|
55
|
+
if (!line.trim()) continue;
|
|
56
|
+
let entry: any;
|
|
57
|
+
try {
|
|
58
|
+
entry = JSON.parse(line);
|
|
59
|
+
} catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (entry?.type === "session_info" && typeof entry.name === "string") name = entry.name.slice(0, 200);
|
|
63
|
+
if (entry?.type !== "message") continue;
|
|
64
|
+
messages++;
|
|
65
|
+
if (first || entry.message?.role !== "user") continue;
|
|
66
|
+
const content = entry.message.content;
|
|
67
|
+
const said = typeof content === "string" ? content : (content ?? []).map((c: any) => (typeof c?.text === "string" ? c.text : "")).join(" ");
|
|
68
|
+
if (said.trim()) first = said.trim().slice(0, 200);
|
|
69
|
+
}
|
|
70
|
+
return { id: header.id, cwd: typeof header.cwd === "string" ? header.cwd : "", startedAt: typeof header.timestamp === "string" ? header.timestamp : undefined, name, first, messages, truncated };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One line of preview: newlines become spaces, and it is cut at `max` *characters* — not bytes,
|
|
75
|
+
* so a CJK character or an emoji at the edge is kept whole — with an ellipsis when something was cut.
|
|
76
|
+
*/
|
|
77
|
+
export function previewText(text: string, max = 80): string {
|
|
78
|
+
const flat = text.replace(/\s*\n\s*/g, " ").trim();
|
|
79
|
+
const chars = [...flat];
|
|
80
|
+
return chars.length > max ? `${chars.slice(0, max).join("")}…` : flat;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The automation badge of a session listing: the plain jobs bound to this session (`sessionId`)
|
|
85
|
+
* and the jobs and rules it created. `2 cron, 1 trigger`; `automation off` when everything
|
|
86
|
+
* it has is disabled; nothing when it has nothing.
|
|
87
|
+
*/
|
|
88
|
+
export function automationBadge(sessionId: string, jobs: LoopJob[], rules: DynamicTriggerRule[]): string | undefined {
|
|
89
|
+
const mine = jobs.filter((j) => j.sessionId === sessionId || j.createdBy?.sessionId === sessionId);
|
|
90
|
+
const rulesMine = rules.filter((r) => r.createdBy?.sessionId === sessionId);
|
|
91
|
+
if (!mine.length && !rulesMine.length) return undefined;
|
|
92
|
+
const parts: string[] = [];
|
|
93
|
+
const cron = mine.filter((j) => j.enabled).length;
|
|
94
|
+
const trig = rulesMine.filter((r) => r.enabled).length;
|
|
95
|
+
if (cron) parts.push(`${cron} cron`);
|
|
96
|
+
if (trig) parts.push(`${trig} trigger`);
|
|
97
|
+
return parts.length ? parts.join(", ") : "automation off";
|
|
98
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* state/<job-id>.md loop state — the "state spine", human-readable
|
|
8
8
|
* inbox.jsonl the triage inbox (see inbox.ts)
|
|
9
9
|
* runs.jsonl run log, bounded
|
|
10
|
-
* scheduler
|
|
10
|
+
* scheduler.json which process on this machine currently owns the timer
|
|
11
11
|
* *.lock mkdir locks
|
|
12
12
|
*/
|
|
13
13
|
import * as fs from "node:fs";
|
|
@@ -53,8 +53,6 @@ export interface LoopJob {
|
|
|
53
53
|
recipe?: string;
|
|
54
54
|
createdAt: string;
|
|
55
55
|
createdBy?: { sessionId?: string; cwd: string };
|
|
56
|
-
/** Host the job belongs to (shared $HOME across machines): other hosts ignore it. Missing = any host (pre-0.1.3). */
|
|
57
|
-
host?: string;
|
|
58
56
|
/** non-stateful jobs only: the session that receives the message. */
|
|
59
57
|
sessionId?: string;
|
|
60
58
|
lastDueAt?: string;
|
|
@@ -219,22 +217,11 @@ export function newId(prefix: string): string {
|
|
|
219
217
|
return `${prefix}-${randomBytes(16).toString("hex")}`;
|
|
220
218
|
}
|
|
221
219
|
|
|
222
|
-
/**
|
|
223
|
-
* A hostname as one filename component: `scheduler.<tag>.json`, `next-runs.<tag>.json`, one
|
|
224
|
-
* presence file per pi. Two machines sharing a `$HOME` must not write over each other, and a
|
|
225
|
-
* hostname may contain characters a path segment may not.
|
|
226
|
-
*
|
|
227
|
-
* `src/web.mjs` spells the same expression out again because it has no imports, and `src/presence.ts`
|
|
228
|
-
* has its own copy; `test/store.test.ts` pins those spellings to this one.
|
|
229
|
-
*/
|
|
230
|
-
export function hostFileTag(host: string = os.hostname()): string {
|
|
231
|
-
return host.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
232
|
-
}
|
|
233
|
-
|
|
234
220
|
/**
|
|
235
221
|
* On-disk shape of `jobs.json`, bumped whenever a field becomes load-bearing, so an older pi-loops sharing this directory refuses
|
|
236
|
-
* the file instead of silently rewriting it without that field. Version 2
|
|
237
|
-
*
|
|
222
|
+
* the file instead of silently rewriting it without that field. Version 2 added `verify`/`checkerModel`,
|
|
223
|
+
* `timeoutMs` and `consecutiveFailures` (and a `host` that gated dispatch until 0.19.0: pi-loops runs
|
|
224
|
+
* on one machine, so the field is ignored when read and dropped when written).
|
|
238
225
|
*/
|
|
239
226
|
export const JOBS_FILE_VERSION = 2;
|
|
240
227
|
|
|
@@ -317,6 +304,9 @@ export class JobStore {
|
|
|
317
304
|
throw new Error(`${this.jobsFile}: version ${parsed.version} was written by a newer pi-loops (this build understands ${JOBS_FILE_VERSION}); upgrade pi-loops`);
|
|
318
305
|
}
|
|
319
306
|
if (!Array.isArray(parsed?.jobs)) throw new Error(`${this.jobsFile}: missing "jobs" array`);
|
|
307
|
+
// Until 0.19.0 a job carried the hostname that created it and other machines ignored it.
|
|
308
|
+
// pi-loops runs on one machine: the stamp is dropped here and gone on the next write.
|
|
309
|
+
for (const j of parsed.jobs) delete (j as { host?: unknown }).host;
|
|
320
310
|
return parsed.jobs;
|
|
321
311
|
}
|
|
322
312
|
|
package/src/tools.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { previewRedacted } from "./redact.ts";
|
|
|
12
12
|
import { computeNext, formatLocal, formatSchedule, parseSchedule, stamp } from "./schedule.ts";
|
|
13
13
|
import type { LoopScheduler, SessionSnapshot } from "./scheduler.ts";
|
|
14
14
|
import { MAX_PROMPT_BYTES, type LoopJob, newId, owningSessionId, resolveJobRef } from "./store.ts";
|
|
15
|
+
import { asleepNote, ownerSession, runsIn } from "./job-owner.ts";
|
|
15
16
|
import type { TriggerRuntime } from "./trigger-runtime.ts";
|
|
16
17
|
import { withinProject } from "./presence.ts";
|
|
17
18
|
import { type TriggerStore, looksLikeFixedScheduleRequest, parseTriggerRule, resolveRuleRef, type DynamicTriggerRule } from "./triggers.ts";
|
|
@@ -153,7 +154,6 @@ export async function createLoopJob(host: Pick<ToolHost, "scheduler" | "session"
|
|
|
153
154
|
createdAt: stamp(),
|
|
154
155
|
// A sub-agent schedules on behalf of the session that runs it.
|
|
155
156
|
createdBy: { sessionId: scope?.parentSessionId ?? host.session().sessionId, cwd: scope?.parentCwd ?? host.session().cwd },
|
|
156
|
-
host: os.hostname(),
|
|
157
157
|
sessionId: owningSessionId(input.stateful, host.session().sessionId, scope?.parentSessionId),
|
|
158
158
|
runCount: 0,
|
|
159
159
|
skippedOverlap: 0,
|
|
@@ -172,18 +172,12 @@ function renderTriggerRulesForTool(rules: ReturnType<TriggerStore["load"]>, host
|
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
* docs/loops.md says as much; `/cron` obeyed it and the model-facing list did not.
|
|
175
|
+
* When the job next runs *here*, or undefined: disabled, or a plain job of a session this process
|
|
176
|
+
* does not hold. The list is filtered the way dispatch filters — a next run for a job nothing here
|
|
177
|
+
* will dispatch is a time nothing intends to keep, and a model reads it as a promise.
|
|
179
178
|
*/
|
|
180
|
-
function
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** When the job next runs, or undefined when nothing here will run it. */
|
|
185
|
-
function nextRunForTool(job: LoopJob, now: number): number | undefined {
|
|
186
|
-
if (!job.enabled || !runsHere(job)) return undefined;
|
|
179
|
+
function nextRunForTool(job: LoopJob, now: number, sessionId: string | undefined): number | undefined {
|
|
180
|
+
if (!job.enabled || !runsIn(job, sessionId)) return undefined;
|
|
187
181
|
return computeNext({ schedule: job.schedule, createdAt: Date.parse(job.createdAt), lastFiredAt: job.lastFiredAt ? Date.parse(job.lastFiredAt) : undefined }, now);
|
|
188
182
|
}
|
|
189
183
|
|
|
@@ -194,8 +188,9 @@ function renderCronJobsForTool(jobs: LoopJob[], host: Pick<ToolHost, "session">)
|
|
|
194
188
|
const lines = [`cron jobs: ${jobs.length}`];
|
|
195
189
|
for (const job of jobs) {
|
|
196
190
|
lines.push(`- ${job.id}${job.name ? ` "${job.name}"` : ""} [${job.enabled ? "enabled" : "disabled"}${job.stateful ? ", stateful" : ""}${job.verify ? ", verify" : ""}] schedule: ${formatSchedule(job.schedule)} action: ${previewRedacted(job.prompt, 120)}${job.cwd !== host.session().cwd ? ` cwd: ${job.cwd}` : ""}`);
|
|
197
|
-
|
|
198
|
-
|
|
191
|
+
const asleep = asleepNote(job, host.session().sessionId);
|
|
192
|
+
if (asleep) lines.push(` ${asleep} (no next run here)`);
|
|
193
|
+
const next = nextRunForTool(job, now, host.session().sessionId);
|
|
199
194
|
if (next) lines.push(` next_run: ${stamp(next)}`);
|
|
200
195
|
if (job.running) lines.push(` running_run_id: ${job.running.runId}`);
|
|
201
196
|
if (job.lastError) lines.push(` last_error: ${previewRedacted(job.lastError, 120)}`);
|
|
@@ -250,7 +245,7 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
250
245
|
const reason = fromSpec ? "create dynamic trigger from `spec` field" : "create dynamic trigger from `condition` + `action` fields";
|
|
251
246
|
const denied = await host.confirmTool(ctx, { label: "create dynamic trigger", tool: "new_trigger", reason, preview: `when ${previewRedacted(condition, 80)} -> ${previewRedacted(action, 80)}`, args: params }, scope.hop);
|
|
252
247
|
if (denied) return deny(denied);
|
|
253
|
-
const rule = await host.triggers.store.add({ condition, action, fireOnce: params.fire_once ?? true, promoteToChat: params.promote_to_chat ?? false, cwd: host.session().cwd, sessionId: host.session().sessionId, model: host.session().model, thinking: host.session().thinking
|
|
248
|
+
const rule = await host.triggers.store.add({ condition, action, fireOnce: params.fire_once ?? true, promoteToChat: params.promote_to_chat ?? false, cwd: host.session().cwd, sessionId: host.session().sessionId, model: host.session().model, thinking: host.session().thinking });
|
|
254
249
|
host.refreshBadge();
|
|
255
250
|
return {
|
|
256
251
|
content: [{ type: "text", text: `created dynamic trigger ${rule.id}\ncondition: ${rule.condition}\naction: ${rule.action}\nfire_once: ${rule.fireOnce}\npromote_to_chat: ${rule.promoteToChat}\n(checked every ${host.triggers.pollIntervalSecs}s by a background sub-agent)` }],
|
|
@@ -342,7 +337,7 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
342
337
|
verify: Type.Optional(Type.Boolean({ description: "Maker/checker: a second adversarial sub-agent verifies each finding before it enters the inbox (stateful jobs only, default false). Use when the user asks for verified, double-checked, or high-precision findings." })),
|
|
343
338
|
name: Type.Optional(Type.String({ description: "Short unique label (letters, digits, . _ -)." })),
|
|
344
339
|
cwd: Type.Optional(Type.String({ description: "Directory a stateful job's sub-agent runs in. Default: current project." })),
|
|
345
|
-
catch_up: Type.Optional(Type.Boolean({ description: "Run once
|
|
340
|
+
catch_up: Type.Optional(Type.Boolean({ description: "Run once for a tick that was missed: a loop when a pi next takes the clock, a plain job when the session that created it is next opened (default: true for stateful jobs, false for plain jobs)." })),
|
|
346
341
|
}),
|
|
347
342
|
async execute(_id, params) {
|
|
348
343
|
const schedule = parseSchedule(params.schedule);
|
|
@@ -365,7 +360,7 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
365
360
|
// Three lines — what was created, when it runs, what it will do — then where its output goes.
|
|
366
361
|
return {
|
|
367
362
|
content: [{ type: "text", text: `created cron job ${job.id}${job.name ? ` "${job.name}"` : ""}\nschedule: ${formatSchedule(job.schedule)}\naction: ${previewRedacted(job.prompt, 120)}\n${job.stateful ? "[stateful] " : ""}next run ${next ? stamp(next) : "—"}. ${where}` }],
|
|
368
|
-
details: { id: job.id, name: job.name, schedule: formatSchedule(job.schedule), action: job.prompt, enabled: job.enabled, stateful: job.stateful, verify: job.verify ?? false, scope: "machine", next_run: next ? stamp(next) : undefined, audit_entry_id: auditEntryId },
|
|
363
|
+
details: { id: job.id, name: job.name, schedule: formatSchedule(job.schedule), action: job.prompt, enabled: job.enabled, stateful: job.stateful, verify: job.verify ?? false, scope: job.stateful ? "machine" : "session", owner_session: ownerSession(job), next_run: next ? stamp(next) : undefined, audit_entry_id: auditEntryId },
|
|
369
364
|
};
|
|
370
365
|
},
|
|
371
366
|
});
|
|
@@ -383,8 +378,8 @@ export function automationTools(scope: ToolScope, host: ToolHost): ToolDefinitio
|
|
|
383
378
|
const jobs = everywhere ? host.scheduler.store.load() : host.scheduler.store.load().filter((j) => sameProject(j.cwd, listCwd));
|
|
384
379
|
const text = `${renderCronJobsForTool(jobs, host)}\ninbox: ${host.scheduler.inbox.newCount()} new finding(s)`;
|
|
385
380
|
const nowMs = Date.now();
|
|
386
|
-
const nextRun = (j: LoopJob) => nextRunForTool(j, nowMs);
|
|
387
|
-
return { content: [{ type: "text", text }], details: { count: jobs.length, scope: everywhere ? "machine" : listCwd, storage_path: host.scheduler.store.jobsFile, jobs: jobs.map((j) => ({ id: j.id, name: j.name, schedule: formatSchedule(j.schedule), action_preview: previewRedacted(j.prompt, 120), enabled: j.enabled, stateful: j.stateful, verify: j.verify ?? false, cwd: j.cwd, running_run_id: j.running?.runId, last_due_at: j.lastDueAt, last_fired_at: j.lastFiredAt, last_completed_at: j.lastCompletedAt, last_error: j.lastError ? previewRedacted(j.lastError, 120) : undefined, skipped_overlap_count: j.skippedOverlap, next_run: (() => { const n = nextRun(j); return n ? stamp(n) : undefined; })(), created_at: j.createdAt })) } };
|
|
381
|
+
const nextRun = (j: LoopJob) => nextRunForTool(j, nowMs, host.session().sessionId);
|
|
382
|
+
return { content: [{ type: "text", text }], details: { count: jobs.length, scope: everywhere ? "machine" : listCwd, storage_path: host.scheduler.store.jobsFile, jobs: jobs.map((j) => ({ id: j.id, name: j.name, schedule: formatSchedule(j.schedule), action_preview: previewRedacted(j.prompt, 120), enabled: j.enabled, stateful: j.stateful, verify: j.verify ?? false, cwd: j.cwd, running_run_id: j.running?.runId, last_due_at: j.lastDueAt, last_fired_at: j.lastFiredAt, last_completed_at: j.lastCompletedAt, last_error: j.lastError ? previewRedacted(j.lastError, 120) : undefined, skipped_overlap_count: j.skippedOverlap, next_run: (() => { const n = nextRun(j); return n ? stamp(n) : undefined; })(), owner_session: ownerSession(j), asleep: !runsIn(j, host.session().sessionId) || undefined, created_at: j.createdAt })) } };
|
|
388
383
|
},
|
|
389
384
|
});
|
|
390
385
|
|
package/src/trigger-runtime.ts
CHANGED
|
@@ -316,8 +316,7 @@ export class TriggerRuntime {
|
|
|
316
316
|
* `ownIfUnowned` is passed straight to `ownsRule`: it only adds the rules nobody owns.
|
|
317
317
|
*/
|
|
318
318
|
private rulesFor(cwd: string | undefined, ownIfUnowned: boolean): { applicable: DynamicTriggerRule[]; owned: DynamicTriggerRule[] } {
|
|
319
|
-
const
|
|
320
|
-
const all = this.store.load().filter((r) => r.enabled && (!r.host || r.host === host));
|
|
319
|
+
const all = this.store.load().filter((r) => r.enabled);
|
|
321
320
|
const applicable = cwd ? all.filter((r) => withinProject(r.cwd, cwd)) : all;
|
|
322
321
|
return { applicable, owned: applicable.filter((r) => this.ownsRule(r, ownIfUnowned)) };
|
|
323
322
|
}
|
|
@@ -333,7 +332,7 @@ export class TriggerRuntime {
|
|
|
333
332
|
// backlog, not the leader's, so leadership does not gate them.
|
|
334
333
|
this.retryPending();
|
|
335
334
|
const host = this.self?.host ?? os.hostname();
|
|
336
|
-
const rules = this.store.load().filter((r) => r.enabled &&
|
|
335
|
+
const rules = this.store.load().filter((r) => r.enabled && this.ownsRule(r, leader) && !this.inBackoff(r, now));
|
|
337
336
|
if (!rules.length) return;
|
|
338
337
|
const byCwd = new Map<string, DynamicTriggerRule[]>();
|
|
339
338
|
for (const r of rules) byCwd.set(r.cwd, [...(byCwd.get(r.cwd) ?? []), r]);
|
|
@@ -386,8 +385,7 @@ export class TriggerRuntime {
|
|
|
386
385
|
if (trigger.source.kind === "mcp" && delivery === "sub_agent" && !trigger.cwd && !this.getSession().cwd) {
|
|
387
386
|
// No project of our own (the headless host): evaluate the push once per project that has
|
|
388
387
|
// rules, each in that project, as tick() does — never all rules at once in $HOME.
|
|
389
|
-
const
|
|
390
|
-
const cwds = [...new Set(this.store.load().filter((r) => r.enabled && (!r.host || r.host === host)).map((r) => r.cwd))];
|
|
388
|
+
const cwds = [...new Set(this.store.load().filter((r) => r.enabled).map((r) => r.cwd))];
|
|
391
389
|
if (!cwds.length) return await this.admit(trigger, delivery); // audited as no_rules
|
|
392
390
|
const outcomes = await Promise.all(cwds.map((cwd, i) => this.admit({ ...trigger, cwd, traceId: i ? newTraceId() : trigger.traceId }, delivery)));
|
|
393
391
|
return outcomes.find(Boolean);
|
package/src/triggers.ts
CHANGED
|
@@ -35,8 +35,6 @@ export interface DynamicTriggerRule {
|
|
|
35
35
|
thinking?: string;
|
|
36
36
|
/** Per-rule cap on the check/action sub-agent; default `[triggers] run_timeout_secs`. */
|
|
37
37
|
timeoutMs?: number;
|
|
38
|
-
/** Host the rule belongs to (shared $HOME): other hosts ignore it. Missing = any host (pre-0.1.3). */
|
|
39
|
-
host?: string;
|
|
40
38
|
createdBy?: { sessionId?: string };
|
|
41
39
|
/** Checks that failed in a row (a check covers every rule it evaluated). Cleared by one that completes; drives the poll backoff in the trigger runtime, as `JobStore`'s field of the same name drives the scheduler's. */
|
|
42
40
|
consecutiveFailures?: number;
|
|
@@ -262,6 +260,8 @@ export class TriggerStore {
|
|
|
262
260
|
if (!text.trim()) return [];
|
|
263
261
|
const parsed = JSON.parse(text) as RulesFile;
|
|
264
262
|
if (!Array.isArray(parsed?.rules)) throw new Error(`${this.rulesFile}: missing "rules" array`);
|
|
263
|
+
// The pre-0.19.0 host stamp, dropped the way jobs.json drops it.
|
|
264
|
+
for (const r of parsed.rules) delete (r as { host?: unknown }).host;
|
|
265
265
|
return parsed.rules;
|
|
266
266
|
}
|
|
267
267
|
|
|
@@ -274,7 +274,7 @@ export class TriggerStore {
|
|
|
274
274
|
});
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
async add(input: { condition: string; action: string; fireOnce?: boolean; promoteToChat?: boolean; cwd: string; sessionId?: string; model?: string; thinking?: string
|
|
277
|
+
async add(input: { condition: string; action: string; fireOnce?: boolean; promoteToChat?: boolean; cwd: string; sessionId?: string; model?: string; thinking?: string }): Promise<DynamicTriggerRule> {
|
|
278
278
|
const condition = input.condition.trim();
|
|
279
279
|
const action = input.action.trim();
|
|
280
280
|
if (!condition || !action) throw new Error("trigger rule needs both a condition and an action");
|
|
@@ -289,7 +289,6 @@ export class TriggerStore {
|
|
|
289
289
|
cwd: input.cwd,
|
|
290
290
|
model: input.model,
|
|
291
291
|
thinking: input.thinking,
|
|
292
|
-
host: input.host,
|
|
293
292
|
createdBy: { sessionId: input.sessionId },
|
|
294
293
|
};
|
|
295
294
|
await this.mutate((rules) => rules.push(rule));
|