@alphacoder-v0/pi-loops 0.22.7 → 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 +681 -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 -41
- 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 +598 -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/job-health.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { FAILURE_BACKOFF_AFTER } from "./scheduler.ts";
|
|
10
10
|
import type { LoopJob } from "./store.ts";
|
|
11
|
+
import { CHECK_FAILURE_BACKOFF_AFTER } from "./trigger-runtime.ts";
|
|
12
|
+
import type { DynamicTriggerRule } from "./triggers.ts";
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* Jobs failing, worst first. The threshold is the scheduler's backoff threshold, which
|
|
@@ -18,11 +20,21 @@ export function failingJobs(jobs: LoopJob[]): LoopJob[] {
|
|
|
18
20
|
return jobs.filter((j) => j.enabled && (j.consecutiveFailures ?? 0) >= FAILURE_BACKOFF_AFTER).sort((a, b) => (b.consecutiveFailures ?? 0) - (a.consecutiveFailures ?? 0));
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
/** `2
|
|
23
|
+
/** `2 loops failing (check-issues ×7)` — one clause, the worst one named, whatever the count. */
|
|
22
24
|
export function failingSummary(jobs: LoopJob[]): string | undefined {
|
|
23
25
|
const failing = failingJobs(jobs);
|
|
24
26
|
const worst = failing[0];
|
|
25
27
|
if (!worst) return undefined;
|
|
26
28
|
const label = worst.name ?? worst.id;
|
|
27
|
-
return `${failing.length}
|
|
29
|
+
return `${failing.length} ${failing.length === 1 ? "loop" : "loops"} failing (${label.length > 24 ? `${label.slice(0, 23)}…` : label} ×${worst.consecutiveFailures})`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `[failing ×N]` for a rule whose checks have failed often enough that the runtime polls it with a
|
|
34
|
+
* widening gap, or undefined. The threshold is the runtime's own, so the listing and the poll cannot
|
|
35
|
+
* disagree; a disabled rule is not polled, so it is not marked, as `failingJobs` skips a disabled job.
|
|
36
|
+
*/
|
|
37
|
+
export function ruleFailureMark(rule: Pick<DynamicTriggerRule, "enabled" | "consecutiveFailures">): string | undefined {
|
|
38
|
+
const n = rule.consecutiveFailures ?? 0;
|
|
39
|
+
return rule.enabled && n >= CHECK_FAILURE_BACKOFF_AFTER ? `[failing ×${n}]` : undefined;
|
|
28
40
|
}
|
package/src/job-lines.ts
CHANGED
|
@@ -6,37 +6,161 @@
|
|
|
6
6
|
* was importable no test could see one go. Everything it reads is a parameter, so a test can hand it
|
|
7
7
|
* a job, a run log and an inbox and read the lines a person reads.
|
|
8
8
|
*/
|
|
9
|
+
import { projectLabel } from "./conversation-rows.ts";
|
|
10
|
+
import { formatWait, queueOrder, queuedPrompt } from "./engine.ts";
|
|
11
|
+
import { shortSessionId } from "./session-head.ts";
|
|
9
12
|
import type { InboxEntry } from "./inbox.ts";
|
|
10
|
-
import { asleepNote,
|
|
13
|
+
import { asleepNote, runsSomewhere, type OwnerView } from "./job-owner.ts";
|
|
11
14
|
import { QUIET_MARK_AFTER, SIGNAL_WINDOW_MS, loopSignal, signalSummary } from "./job-signal.ts";
|
|
12
15
|
import { previewRedacted } from "./redact.ts";
|
|
16
|
+
import type { SessionRequest } from "./requests.ts";
|
|
13
17
|
import { computeNext, formatLocal, formatSchedule } from "./schedule.ts";
|
|
14
18
|
import type { LoopJob, RunRecord } from "./store.ts";
|
|
19
|
+
import type { DynamicTriggerRule } from "./triggers.ts";
|
|
15
20
|
|
|
16
21
|
export interface JobLinesInput {
|
|
17
22
|
/** The whole run log, read once by the caller; only this listing's loops are filtered out of it. */
|
|
18
23
|
runs: RunRecord[];
|
|
19
24
|
/** The whole inbox, read once by the caller. */
|
|
20
25
|
inbox: InboxEntry[];
|
|
21
|
-
/**
|
|
22
|
-
sessionId
|
|
26
|
+
/** A conversation's queued rows (`RequestStore.list`): a plain job whose prompt waits there is marked `[queued …]`. */
|
|
27
|
+
requests: (sessionId: string) => SessionRequest[];
|
|
28
|
+
/** Who holds which session, as the instance reading the list sees it: a plain job of a session nobody holds has no next run. */
|
|
29
|
+
view: OwnerView;
|
|
23
30
|
now: number;
|
|
24
31
|
/** `$HOME`, for `~` shortening; passed rather than read so a test's paths are its own. */
|
|
25
32
|
home: string;
|
|
26
33
|
/** Injected because the answer decides `[orphan: cwd missing]`, and a test should not make directories. */
|
|
27
34
|
cwdExists: (cwd: string) => boolean;
|
|
35
|
+
/** Whether each job's path is printed: only `/cron all` lists more than one project. */
|
|
36
|
+
showCwd: boolean;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
39
|
+
/**
|
|
40
|
+
* What a person calls a loop on every surface but the lists that take its id (`/cron`, `cron_list`,
|
|
41
|
+
* `inbox --json`): its name, else the first 13 characters of its id (`cron-` and 8 hex digits).
|
|
42
|
+
*/
|
|
43
|
+
export function loopLabel(job: { id: string; name?: string }): string {
|
|
44
|
+
return job.name || job.id.slice(0, 13);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A loop's generated id (`src/store.ts` newId): what the snapshot carries for a loop with no name. */
|
|
48
|
+
const LOOP_ID = /^cron-[0-9a-f]{32}$/;
|
|
49
|
+
|
|
50
|
+
/** A snapshot's `job` field (its name, else its id) as every surface labels the loop. */
|
|
51
|
+
export function snapshotLoopLabel(job: string): string {
|
|
52
|
+
return LOOP_ID.test(job) ? loopLabel({ id: job }) : job;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
/** What a queued row's source is called in `/cron queue`, the words the browser page uses for the same row. */
|
|
57
|
+
export function queueSourceLabel(source: SessionRequest["source"], jobName: (id: string) => string | undefined): string {
|
|
58
|
+
const at = source.indexOf(":");
|
|
59
|
+
const [kind, arg] = at < 0 ? [source, ""] : [source.slice(0, at), source.slice(at + 1)];
|
|
60
|
+
if (source === "person") return "you";
|
|
61
|
+
if (kind === "cron") return `cron ${jobName(arg) ?? arg}`;
|
|
62
|
+
if (kind === "cron-run") return `/cron run ${jobName(arg) ?? arg}`;
|
|
63
|
+
if (kind === "rule" || kind === "inbox") return `${kind === "inbox" ? "claim" : kind} ${arg.slice(0, 8)}`;
|
|
64
|
+
if (kind === "push" || kind === "recipe") return `${kind} ${arg}`;
|
|
65
|
+
return source;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface QueueLinesInput {
|
|
69
|
+
now: number;
|
|
70
|
+
/** A plain job's name by id, for the clock's rows. */
|
|
71
|
+
jobName: (id: string) => string | undefined;
|
|
72
|
+
/** Rows pi was handed and has not written yet: listed, numbered, and said to be pi's already. */
|
|
73
|
+
inFlight: ReadonlySet<string>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `/cron queue`: what waits for this conversation, in the order the engine offers it, one numbered
|
|
78
|
+
* line each with its source, how long it has waited and the start of its text. The numbers are what
|
|
79
|
+
* `/cron queue cancel <n>` takes, so they are the listing's and nothing else's.
|
|
80
|
+
*/
|
|
81
|
+
export function queueLines(rows: readonly SessionRequest[], opts: QueueLinesInput): string[] {
|
|
82
|
+
const inputs = queueOrder(rows);
|
|
83
|
+
if (!inputs.length) return ["(nothing is queued for this conversation)"];
|
|
84
|
+
return inputs.map((row, i) => {
|
|
85
|
+
const created = Date.parse(row.created_at);
|
|
86
|
+
const age = Number.isFinite(created) ? formatWait(opts.now - created) : "?";
|
|
87
|
+
const state = opts.inFlight.has(row.id) ? "handed to pi" : row.not_before && Date.parse(row.not_before) > opts.now ? `backing off${row.last_error ? `: ${previewRedacted(row.last_error, 80)}` : ""}` : row.wake === "append" ? "lands without a turn" : "waits for pi to be idle";
|
|
88
|
+
const text = previewRedacted(String(row.payload?.text ?? "").replace(/^\[Trigger [^\]]+\]\s*/, ""), 100) || (row.payload?.images?.length ? `[${plural(row.payload.images.length, "image")}]` : "");
|
|
89
|
+
return `${String(i + 1).padStart(2)}. ${queueSourceLabel(row.source, opts.jobName)} · ${age} · ${state} · ${row.id}\n ${text}`;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Which queued row `ref` names, for `/cron queue cancel`: its number in the listing, its id, or a
|
|
95
|
+
* prefix of its id that names one row. Undefined when nothing matches; a prefix of several is nobody.
|
|
96
|
+
*/
|
|
97
|
+
export function pickQueued(rows: readonly SessionRequest[], ref: string): SessionRequest | undefined {
|
|
98
|
+
const inputs = queueOrder(rows);
|
|
99
|
+
const trimmed = ref.trim();
|
|
100
|
+
if (/^\d+$/.test(trimmed)) return inputs[Number(trimmed) - 1];
|
|
101
|
+
const exact = inputs.find((row) => row.id === trimmed);
|
|
102
|
+
if (exact) return exact;
|
|
103
|
+
const prefixed = trimmed ? inputs.filter((row) => row.id.startsWith(trimmed)) : [];
|
|
104
|
+
return prefixed.length === 1 ? prefixed[0] : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `1 loop`, `2 loops`: a count a person reads, never `loop(s)`. */
|
|
108
|
+
export function plural(n: number, one: string, many = `${one}s`): string {
|
|
109
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The one-time line a session start says when plain jobs carry `--tools`: 0.22.8 stored the list
|
|
114
|
+
* and never used it, and since 0.23 a turn in a window asks before a tool outside it. Undefined when
|
|
115
|
+
* no enabled plain job has one. `names` are what `/cron set` takes.
|
|
116
|
+
*/
|
|
117
|
+
export function toolsCapNotice(jobs: readonly LoopJob[]): string | undefined {
|
|
118
|
+
const capped = jobs.filter((j) => j.enabled && !j.stateful && j.tools?.length);
|
|
119
|
+
if (!capped.length) return undefined;
|
|
120
|
+
const names = capped.map((j) => j.name ?? j.id);
|
|
121
|
+
const one = capped.length === 1;
|
|
122
|
+
return `[cron] ${plural(capped.length, "plain job")} ${one ? "has" : "have"} --tools, which now limit ${one ? "its" : "their"} turns (a tool outside the list asks you first in a window, and is refused in the background): ${names.join(", ")} — /cron set <ref> --tools - clears it`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The one-time line a session start says about plain jobs running in the background: every enabled
|
|
127
|
+
* plain job of every conversation, since each runs there once its conversation has been opened in
|
|
128
|
+
* this pi-loops and closed again. 0.22.8 slept them while their conversation was closed, and closing
|
|
129
|
+
* it was how people stopped one. Undefined when there is none.
|
|
130
|
+
*/
|
|
131
|
+
export function heldJobsNotice(jobs: readonly LoopJob[]): string | undefined {
|
|
132
|
+
const plain = jobs.filter((j) => j.enabled && !j.stateful && j.sessionId);
|
|
133
|
+
if (!plain.length) return undefined;
|
|
134
|
+
const conversations = new Set(plain.map((j) => j.sessionId)).size;
|
|
135
|
+
const one = plain.length === 1;
|
|
136
|
+
return `[cron] ${plural(plain.length, "plain job")} in ${plural(conversations, "conversation")} ${one ? "runs" : "run"} in the background while ${one ? "its" : "their"} conversation is closed (once it has been opened in this pi-loops), and each run is a model turn — /cron all lists them`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* What a window says when it leaves a conversation (`/new`, `/resume`) that has plain jobs: in 0.22.8
|
|
141
|
+
* that stopped them; now they run in the background, or, with `[host] auto = false`, stop with the
|
|
142
|
+
* last window. Undefined when the conversation has none.
|
|
143
|
+
*/
|
|
144
|
+
export function leftConversationLine(sessionId: string, plainJobs: number, hostAuto: boolean): string | undefined {
|
|
145
|
+
if (!plainJobs) return undefined;
|
|
146
|
+
const run = plainJobs === 1 ? "runs" : "run";
|
|
147
|
+
const where = hostAuto ? `${run} in the background now, each run a model turn` : `${run} in the background while a pi window is open ([host] auto = false), each run a model turn`;
|
|
148
|
+
return `[cron] the conversation you left (${shortSessionId(sessionId)}) has ${plural(plainJobs, "plain job")} that ${where} — /cron all lists them, /cron disable <ref> stops one`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A catch-up setting that differs from the default (loops catch up, plain jobs do not), in the flag's own spelling. */
|
|
152
|
+
function catchUpMark(job: Pick<LoopJob, "stateful" | "catchUp">): string | undefined {
|
|
153
|
+
if (job.stateful) return job.catchUp ? undefined : "[no-catchup]";
|
|
154
|
+
return job.catchUp ? "[catchup]" : undefined;
|
|
155
|
+
}
|
|
32
156
|
|
|
33
157
|
export function jobLines(jobs: LoopJob[], opts: JobLinesInput): string[] {
|
|
34
|
-
const { runs, inbox,
|
|
158
|
+
const { runs, inbox, requests, view, now, home, cwdExists, showCwd } = opts;
|
|
35
159
|
return jobs
|
|
36
160
|
.map((job, i) => {
|
|
37
|
-
// A next run is shown
|
|
161
|
+
// A next run is shown when it will happen somewhere: a plain job of a session nobody holds has none.
|
|
38
162
|
const next =
|
|
39
|
-
job.enabled &&
|
|
163
|
+
job.enabled && runsSomewhere(job, view)
|
|
40
164
|
? computeNext(
|
|
41
165
|
{
|
|
42
166
|
schedule: job.schedule,
|
|
@@ -46,7 +170,7 @@ export function jobLines(jobs: LoopJob[], opts: JobLinesInput): string[] {
|
|
|
46
170
|
now,
|
|
47
171
|
)
|
|
48
172
|
: undefined;
|
|
49
|
-
const asleepMark = asleepNote(job,
|
|
173
|
+
const asleepMark = asleepNote(job, view);
|
|
50
174
|
const dormant = asleepMark ? `[${asleepMark}]` : undefined;
|
|
51
175
|
const orphan = job.stateful && !cwdExists(job.cwd) ? "[orphan: cwd missing]" : undefined;
|
|
52
176
|
// What the loop's runs came to (src/job-signal.ts): `quiet ×N` in the marks once N empty
|
|
@@ -54,15 +178,79 @@ export function jobLines(jobs: LoopJob[], opts: JobLinesInput): string[] {
|
|
|
54
178
|
// A loop was, until now, judged by whether it ran; this is whether it was worth running.
|
|
55
179
|
const signal = job.stateful ? loopSignal(job.id, runs, inbox, now - SIGNAL_WINDOW_MS) : undefined;
|
|
56
180
|
const quiet = signal && signal.quiet >= QUIET_MARK_AFTER ? `[quiet ×${signal.quiet}]` : undefined;
|
|
57
|
-
|
|
181
|
+
// A plain job's prompt waiting in its conversation's queue (for pi to be idle, after Esc,
|
|
182
|
+
// behind another turn): without this mark it looked exactly like one that is not queued.
|
|
183
|
+
const waiting = !job.stateful && job.sessionId ? queuedPrompt(job, requests(job.sessionId), now) : undefined;
|
|
184
|
+
const queued = waiting ? `[queued ${formatWait(now - Date.parse(waiting.since))}${waiting.reason ? ` — ${waiting.reason}` : ""}]` : undefined;
|
|
185
|
+
const marks = [job.stateful ? "[stateful]" : undefined, job.verify ? "[verify]" : undefined, dormant, orphan, quiet, queued, job.running ? `running ${job.running.runId}` : undefined, catchUpMark(job)]
|
|
58
186
|
.filter(Boolean)
|
|
59
187
|
.join(" ");
|
|
60
|
-
const head = `${String(i + 1).padStart(2)}. ${job.id}${job.name ? ` "${job.name}"` : ""}
|
|
188
|
+
const head = `${String(i + 1).padStart(2)}. ${job.id}${job.name ? ` "${job.name}"` : ""}${job.enabled ? "" : " disabled"} ${formatSchedule(job.schedule)}${marks ? ` ${marks}` : ""}`;
|
|
61
189
|
const action = ` action: ${previewRedacted(job.prompt, 120)}`;
|
|
62
190
|
const worth = signal ? signalSummary(signal) : undefined;
|
|
63
|
-
const meta = ` next ${next ? formatLocal(next) : "—"} · runs ${job.runCount}${job.skippedOverlap ? ` · skipped overlaps ${job.skippedOverlap}` : ""}${worth ? ` · 30d: ${worth}` : ""} · ${
|
|
64
|
-
const err = job.lastError ? ` last error: ${previewRedacted(job.lastError, 100)}` : job.lastFiredAt ? ` last fired: ${job.lastFiredAt}` : undefined;
|
|
191
|
+
const meta = ` next ${next ? formatLocal(next) : "—"} · runs ${job.runCount}${job.skippedOverlap ? ` · skipped overlaps ${job.skippedOverlap}` : ""}${worth ? ` · 30d: ${worth}` : ""}${showCwd ? ` · ${projectLabel(job.cwd, home)}` : ""}`;
|
|
192
|
+
const err = job.lastError ? ` last error: ${previewRedacted(job.lastError, 100)}` : job.lastFiredAt ? ` last fired: ${formatLocal(Date.parse(job.lastFiredAt))}` : undefined;
|
|
65
193
|
return [head, action, meta, err].filter((l): l is string => !!l);
|
|
66
194
|
})
|
|
67
195
|
.flat();
|
|
68
196
|
}
|
|
197
|
+
|
|
198
|
+
/** More than five rules or loops is a list, not a panel: `/triggers` and `/cron` have the rest. */
|
|
199
|
+
const PANEL_ROW_LIMIT = 5;
|
|
200
|
+
/** The latest runs; `/cron runs` has the rest. */
|
|
201
|
+
export const PANEL_RUN_LIMIT = 3;
|
|
202
|
+
|
|
203
|
+
export type PanelTone = "head" | "ok" | "dim" | "warn";
|
|
204
|
+
|
|
205
|
+
/** What the widget shows for one project. The run line and a rule's failure mark come in made, as the run card and `/triggers` make them. */
|
|
206
|
+
export interface PanelInput {
|
|
207
|
+
rules: (Pick<DynamicTriggerRule, "condition" | "action" | "enabled" | "fireOnce"> & { failureMark?: string })[];
|
|
208
|
+
/** The last rule check here; only an outcome other than `no match` or `matched N` is shown. */
|
|
209
|
+
poll?: { at: string; outcome: string };
|
|
210
|
+
jobs: LoopJob[];
|
|
211
|
+
runs: RunRecord[];
|
|
212
|
+
runText: (r: RunRecord, name?: string) => string;
|
|
213
|
+
sources: { name: string; state: string; tools: number }[];
|
|
214
|
+
hookEvents: string[];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The widget's lines, each with the tone the theme colours it in; none when there is nothing to
|
|
219
|
+
* show. A row carries a mark only where it differs (`[disabled]`, `[every time]`): the inbox count
|
|
220
|
+
* is the footer badge's, and the trace of a check is `/triggers audit`'s.
|
|
221
|
+
*/
|
|
222
|
+
export function panelLines(i: PanelInput): { text: string; tone: PanelTone }[] {
|
|
223
|
+
if (!i.rules.length && !i.jobs.length && !i.sources.length && !i.runs.length) return [];
|
|
224
|
+
const clip = (x: string, n: number) => previewRedacted(x, n);
|
|
225
|
+
const lines: { text: string; tone: PanelTone }[] = [];
|
|
226
|
+
const more = (n: number) => n > PANEL_ROW_LIMIT && lines.push({ text: `… ${n - PANEL_ROW_LIMIT} more`, tone: "dim" });
|
|
227
|
+
if (i.rules.length) {
|
|
228
|
+
lines.push({ text: "Rules", tone: "head" });
|
|
229
|
+
for (const r of i.rules.slice(0, PANEL_ROW_LIMIT)) {
|
|
230
|
+
const marks = [r.enabled ? "" : "[disabled]", r.fireOnce ? "" : "[every time]", r.failureMark ?? ""].filter(Boolean);
|
|
231
|
+
lines.push({ text: `${clip(r.condition, 40)} → ${clip(r.action, 40)}${marks.map((m) => ` ${m}`).join("")}`, tone: r.failureMark ? "warn" : r.enabled ? "ok" : "dim" });
|
|
232
|
+
}
|
|
233
|
+
more(i.rules.length);
|
|
234
|
+
}
|
|
235
|
+
if (i.poll && i.poll.outcome !== "no match" && !/^matched \d+$/.test(i.poll.outcome)) lines.push({ text: `Last check ${formatLocal(Date.parse(i.poll.at)).slice(11)} · ${i.poll.outcome}`, tone: "warn" });
|
|
236
|
+
if (i.jobs.length) {
|
|
237
|
+
const disabled = i.jobs.filter((j) => !j.enabled).length;
|
|
238
|
+
lines.push({ text: `Loops ${i.jobs.length}${disabled ? ` · ${disabled} disabled` : ""}`, tone: "head" });
|
|
239
|
+
for (const j of i.jobs.slice(0, PANEL_ROW_LIMIT))
|
|
240
|
+
lines.push({ text: `${loopLabel(j)} ${formatSchedule(j.schedule)} ${clip(j.prompt, 48)}${j.enabled ? "" : " [disabled]"}${j.skippedOverlap ? ` skipped overlaps ${j.skippedOverlap}` : ""}`, tone: j.skippedOverlap ? "warn" : j.enabled ? "ok" : "dim" });
|
|
241
|
+
more(i.jobs.length);
|
|
242
|
+
}
|
|
243
|
+
if (i.runs.length) {
|
|
244
|
+
lines.push({ text: "Runs", tone: "head" });
|
|
245
|
+
const names = new Map(i.jobs.map((j) => [j.id, j.name]));
|
|
246
|
+
for (const r of i.runs.slice(-PANEL_RUN_LIMIT).reverse()) lines.push({ text: `${formatLocal(Date.parse(r.finishedAt)).slice(11)} ${i.runText(r, names.get(r.jobId))}`, tone: r.ok ? "ok" : r.aborted ? "dim" : "warn" });
|
|
247
|
+
if (i.runs.length > PANEL_RUN_LIMIT) lines.push({ text: "… /cron runs", tone: "dim" });
|
|
248
|
+
}
|
|
249
|
+
if (i.sources.length) {
|
|
250
|
+
lines.push({ text: `MCP servers ${i.sources.length} · tools ${i.sources.reduce((n, s) => n + s.tools, 0)}`, tone: "head" });
|
|
251
|
+
const tone: PanelTone = i.sources.some((s) => s.state !== "connected" && s.state !== "disabled") ? "warn" : i.sources.some((s) => s.state === "connected") ? "ok" : "dim";
|
|
252
|
+
lines.push({ text: ` ${i.sources.map((s) => `${s.name} ${s.state}${s.tools ? ` · ${plural(s.tools, "tool")}` : ""}`).join(" ")}`, tone });
|
|
253
|
+
}
|
|
254
|
+
if (i.hookEvents.length) lines.push({ text: `Hooks cli_hooks ${plural(i.hookEvents.length, "rule")} (${[...new Set(i.hookEvents)].join(", ")})`, tone: "head" });
|
|
255
|
+
return lines;
|
|
256
|
+
}
|
package/src/job-owner.ts
CHANGED
|
@@ -1,29 +1,90 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Where a job runs, as one question the scheduler, the lists and `/cron run` all ask the same way.
|
|
3
3
|
*
|
|
4
|
-
* A stateful loop belongs to the machine:
|
|
4
|
+
* A stateful loop belongs to the machine: the host runs it. A plain job
|
|
5
5
|
* belongs to the session that created it — its result is a message in that conversation, so it
|
|
6
|
-
* fires
|
|
7
|
-
*
|
|
6
|
+
* fires in the instance holding that session's lease (src/lease.ts), and in the host while no window
|
|
7
|
+
* holds it. Another window on the same session is a viewer and fires nothing. A session the host
|
|
8
|
+
* cannot hold (a file pi-loops never saw) is asleep; it is not broken, and
|
|
9
|
+
* nothing here promises it a next run it will not get.
|
|
8
10
|
*/
|
|
11
|
+
import { shortSessionId } from "./session-head.ts";
|
|
9
12
|
import type { LoopJob } from "./store.ts";
|
|
10
13
|
|
|
11
|
-
/**
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
/** What the instance asking knows about sessions. */
|
|
15
|
+
export interface OwnerView {
|
|
16
|
+
/** The session this instance has open. */
|
|
17
|
+
sessionId?: string;
|
|
18
|
+
/** Whether it holds that session's lease. A second window on the same conversation does not. */
|
|
19
|
+
holdsOwn: boolean;
|
|
20
|
+
/** The pid of the live holder of a session's lease, when there is one. */
|
|
21
|
+
holderOf?: (sessionId: string) => number | undefined;
|
|
22
|
+
/** It holds that session's lease but stands aside: its copy of the conversation is behind the file (src/lease.ts). */
|
|
23
|
+
standsAside?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Why no host will run anything right now, when that is so: stopped by hand, `[host] auto = false`,
|
|
26
|
+
* `PI_LOOPS_HOST=off`, paused after crashes. Every loop, and every plain job only the host would
|
|
27
|
+
* fire, is then paused: no next run is promised for a run that will not happen.
|
|
28
|
+
*/
|
|
29
|
+
hostPaused?: string;
|
|
30
|
+
/** The kind of that live holder: the host holding a conversation is running a turn in it; `page` is the page's pi. */
|
|
31
|
+
holderKind?: (sessionId: string) => "terminal" | "page" | "host" | undefined;
|
|
32
|
+
/** Whether the host fires a closed session's plain jobs: it is running and knows the session's file. */
|
|
33
|
+
hostRuns?: (sessionId: string) => boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `terminal` is any window that holds the conversation; `page` says it is the page's pi. */
|
|
37
|
+
export type Where = { kind: "machine" } | { kind: "here" } | { kind: "aside"; pid: number } | { kind: "terminal"; pid: number; page?: true } | { kind: "host" } | { kind: "paused"; reason: string } | { kind: "asleep" };
|
|
38
|
+
|
|
39
|
+
type Owned = Pick<LoopJob, "stateful" | "sessionId">;
|
|
40
|
+
|
|
41
|
+
export function whereRuns(job: Owned, view: OwnerView): Where {
|
|
42
|
+
if (job.stateful) return view.hostPaused ? { kind: "paused", reason: view.hostPaused } : { kind: "machine" };
|
|
43
|
+
if (!job.sessionId) return { kind: "asleep" };
|
|
44
|
+
if (job.sessionId === view.sessionId && view.holdsOwn) return { kind: "here" };
|
|
45
|
+
const pid = view.holderOf?.(job.sessionId);
|
|
46
|
+
const holder = pid === undefined ? undefined : view.holderKind?.(job.sessionId);
|
|
47
|
+
if (pid !== undefined && holder === "host") return { kind: "host" };
|
|
48
|
+
if (pid !== undefined && job.sessionId === view.sessionId && view.standsAside) return { kind: "aside", pid };
|
|
49
|
+
if (pid !== undefined) return holder === "page" ? { kind: "terminal", pid, page: true } : { kind: "terminal", pid };
|
|
50
|
+
if (!view.hostRuns?.(job.sessionId)) return { kind: "asleep" };
|
|
51
|
+
return view.hostPaused ? { kind: "paused", reason: view.hostPaused } : { kind: "host" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Would this instance dispatch the job? (Only the host's scheduler is given loops.) */
|
|
55
|
+
export function runsHere(job: Owned, view: OwnerView): boolean {
|
|
56
|
+
const where = whereRuns(job, view).kind;
|
|
57
|
+
return where === "machine" || where === "here";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether the job has a next run anywhere: a list shows it, wherever that is. */
|
|
61
|
+
export function runsSomewhere(job: Owned, view: OwnerView): boolean {
|
|
62
|
+
const kind = whereRuns(job, view).kind;
|
|
63
|
+
return kind !== "asleep" && kind !== "paused";
|
|
15
64
|
}
|
|
16
65
|
|
|
17
66
|
/** The short form of the session a plain job belongs to. */
|
|
18
|
-
export function ownerSession(job:
|
|
19
|
-
return job.stateful ? undefined : (job.sessionId ?? "?")
|
|
67
|
+
export function ownerSession(job: Owned): string | undefined {
|
|
68
|
+
return job.stateful ? undefined : shortSessionId(job.sessionId ?? "?");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Where a conversation is open, in the words every list and notice uses: never a pid. */
|
|
72
|
+
export function openWhere(holder: "terminal" | "page" | "host" | undefined): string {
|
|
73
|
+
if (holder === "page") return "in the page";
|
|
74
|
+
if (holder === "host") return "in the background";
|
|
75
|
+
return "open in a terminal";
|
|
20
76
|
}
|
|
21
77
|
|
|
22
78
|
/**
|
|
23
|
-
* The
|
|
79
|
+
* The mark a list shows for a plain job that will not run here — worded as what to do, because
|
|
24
80
|
* "not open here" was read as an error. Undefined when the job runs here.
|
|
25
81
|
*/
|
|
26
|
-
export function asleepNote(job:
|
|
27
|
-
|
|
28
|
-
return `session ${ownerSession(job)} —
|
|
82
|
+
export function asleepNote(job: Owned, view: OwnerView): string | undefined {
|
|
83
|
+
const where = whereRuns(job, view);
|
|
84
|
+
if (where.kind === "terminal") return `session ${ownerSession(job)} — ${openWhere(where.page ? "page" : "terminal")}`;
|
|
85
|
+
if (where.kind === "aside") return `session ${ownerSession(job)} — this window shows an older copy; /resume it to run here`;
|
|
86
|
+
if (where.kind === "host") return `session ${ownerSession(job)} — ${openWhere("host")}`;
|
|
87
|
+
if (where.kind === "paused") return job.stateful ? `paused: ${where.reason}` : `session ${ownerSession(job)} — paused: ${where.reason}`;
|
|
88
|
+
if (where.kind === "asleep") return `session ${ownerSession(job)} — resume it to run`;
|
|
89
|
+
return undefined;
|
|
29
90
|
}
|
package/src/job-tune.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the page suggests for a loop, as a function: at most one change, decided from the loop's
|
|
3
|
+
* signal (src/job-signal.ts), never applied by itself.
|
|
4
|
+
*
|
|
5
|
+
* Two kinds. A loop that has found nothing in its last `QUIET_SUGGEST_AFTER` runs and runs more
|
|
6
|
+
* often than hourly is offered "Run hourly instead"; one whose recent findings were all dismissed
|
|
7
|
+
* and none claimed is offered "Pause". A quiet hourly or daily loop gets nothing: quiet is not a
|
|
8
|
+
* fault (docs/loops.md, "Was it worth running").
|
|
9
|
+
*
|
|
10
|
+
* The key names the loop, the kind and the schedule it was computed under. The page posts only the
|
|
11
|
+
* key back; the server computes the suggestion again and refuses a key that no longer matches, so
|
|
12
|
+
* a page can never ask for a change the server would not suggest. Keep records the key on the job
|
|
13
|
+
* (`tuneKept`): the same suggestion is not offered again, and a schedule change makes a new key.
|
|
14
|
+
*/
|
|
15
|
+
import { applyJobEdit, type JobEditContext, type NextRun } from "./job-edit.ts";
|
|
16
|
+
import type { LoopSignal } from "./job-signal.ts";
|
|
17
|
+
import { computeNext, formatSchedule, type Schedule } from "./schedule.ts";
|
|
18
|
+
import type { LoopJob } from "./store.ts";
|
|
19
|
+
import type { AuditRecord } from "./triggers.ts";
|
|
20
|
+
|
|
21
|
+
/** "Run hourly instead" from this many empty runs in a row. */
|
|
22
|
+
export const QUIET_SUGGEST_AFTER = 12;
|
|
23
|
+
/** "Pause" when the window has at least this many findings and this many dismissed, and none claimed. */
|
|
24
|
+
export const PAUSE_FINDINGS = 5;
|
|
25
|
+
export const PAUSE_DISMISSED = 5;
|
|
26
|
+
const HOUR_MS = 3_600_000;
|
|
27
|
+
|
|
28
|
+
export type TuneKind = "slower" | "pause";
|
|
29
|
+
|
|
30
|
+
export interface Suggestion {
|
|
31
|
+
kind: TuneKind;
|
|
32
|
+
key: string;
|
|
33
|
+
label: "Run hourly instead" | "Pause";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The numbers a loop row shows: `30d: 6 findings · 0 claimed · 6 dismissed · quiet ×12`. */
|
|
37
|
+
export interface RowSignal {
|
|
38
|
+
findings30d: number;
|
|
39
|
+
claimed30d: number;
|
|
40
|
+
dismissed30d: number;
|
|
41
|
+
quietStreak: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function rowSignal(s: LoopSignal): RowSignal {
|
|
45
|
+
return { findings30d: s.findings, claimed30d: s.claimed, dismissed30d: s.dismissed, quietStreak: s.quiet };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How long the schedule waits between two runs from `now`; undefined when it has no two. */
|
|
49
|
+
function periodMs(schedule: Schedule, createdAt: number, now: number): number | undefined {
|
|
50
|
+
if (schedule.kind === "every") return schedule.ms;
|
|
51
|
+
if (schedule.kind !== "cron") return undefined;
|
|
52
|
+
const first = computeNext({ schedule, createdAt }, now);
|
|
53
|
+
const second = first === undefined ? undefined : computeNext({ schedule, createdAt }, first);
|
|
54
|
+
return first === undefined || second === undefined ? undefined : second - first;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The hourly schedule of the same kind: `every 1h`, or the top of every hour. */
|
|
58
|
+
function hourly(schedule: Schedule): Schedule {
|
|
59
|
+
return schedule.kind === "every" ? { kind: "every", ms: HOUR_MS } : { kind: "cron", expr: "0 * * * *" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** At most one suggestion for `job`, or none. */
|
|
63
|
+
export function suggestTune(job: LoopJob, s: LoopSignal, now: number): Suggestion | undefined {
|
|
64
|
+
if (!job.enabled || !job.stateful || job.schedule.kind === "once" || job.running) return undefined;
|
|
65
|
+
const at = `${job.id}:${formatSchedule(job.schedule)}`;
|
|
66
|
+
let suggestion: Suggestion | undefined;
|
|
67
|
+
if (s.findings >= PAUSE_FINDINGS && s.claimed === 0 && s.dismissed >= PAUSE_DISMISSED) suggestion = { kind: "pause", key: `pause:${at}`, label: "Pause" };
|
|
68
|
+
else if (s.quiet >= QUIET_SUGGEST_AFTER) {
|
|
69
|
+
const period = periodMs(job.schedule, Date.parse(job.createdAt), now);
|
|
70
|
+
if (period !== undefined && period < HOUR_MS) suggestion = { kind: "slower", key: `slower:${at}`, label: "Run hourly instead" };
|
|
71
|
+
}
|
|
72
|
+
return suggestion && job.tuneKept === suggestion.key ? undefined : suggestion;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AppliedTune {
|
|
76
|
+
/** Exactly the fields to write, under the store's lock. */
|
|
77
|
+
patch: Partial<LoopJob>;
|
|
78
|
+
/** The `cron_control_plane` state: a schedule change is `set`, a pause is `disable`. */
|
|
79
|
+
op: "set" | "disable";
|
|
80
|
+
changed: string[];
|
|
81
|
+
nextRun: NextRun;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Apply `suggestion` to `job`, through `applyJobEdit` for a schedule. */
|
|
85
|
+
export function applyTune(job: LoopJob, suggestion: Suggestion, ctx: JobEditContext): AppliedTune {
|
|
86
|
+
if (suggestion.kind === "pause") return { patch: { enabled: false, tuneKept: undefined }, op: "disable", changed: ["paused from the page"], nextRun: { kind: "disabled" } };
|
|
87
|
+
const edit = applyJobEdit(job, { schedule: hourly(job.schedule) }, ctx);
|
|
88
|
+
return { patch: { ...edit.patch, tuneKept: undefined }, op: "set", changed: edit.changed, nextRun: edit.nextRun };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The `cron_control_plane` audit row for a loop changed in place, from the page's suggestion or the
|
|
93
|
+
* terminal's `/cron set`: `set` when its schedule changed, `disable` when it was turned off, and
|
|
94
|
+
* nothing for any other edit (a model, a name, a prompt), which the log already records.
|
|
95
|
+
*/
|
|
96
|
+
export function scheduleAudit(before: LoopJob, after: LoopJob, by: { sourceLabel: "page" | "slash"; suggestion?: string }): Omit<AuditRecord, "ts" | "traceId"> | undefined {
|
|
97
|
+
const was = formatSchedule(before.schedule);
|
|
98
|
+
const now = formatSchedule(after.schedule);
|
|
99
|
+
const state = was !== now ? "set" : before.enabled && !after.enabled ? "disable" : undefined;
|
|
100
|
+
if (!state) return undefined;
|
|
101
|
+
return {
|
|
102
|
+
cwd: before.cwd,
|
|
103
|
+
type: "cron_control_plane",
|
|
104
|
+
state,
|
|
105
|
+
sourceLabel: by.sourceLabel,
|
|
106
|
+
eventLabel: before.id,
|
|
107
|
+
summary: `${before.name ?? before.id}: ${state === "set" ? `${was} → ${now}` : "paused"}`,
|
|
108
|
+
details: { before_schedule: was, after_schedule: now, before_enabled: before.enabled, after_enabled: after.enabled, ...(by.suggestion ? { suggestion: by.suggestion } : {}) },
|
|
109
|
+
};
|
|
110
|
+
}
|