@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/engine.ts
ADDED
|
@@ -0,0 +1,1240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one engine that delivers a session's requests (src/requests.ts). `decideDrain` is pure: the
|
|
3
|
+
* rows, what the conversation already holds, the jobs and the executor's view in, the actions out.
|
|
4
|
+
* `drain` applies them through an executor: the window that holds the conversation
|
|
5
|
+
* (src/window-executor.ts), or the background process for a closed one (src/host-runtime.ts). A
|
|
6
|
+
* local row (what the person typed, a goal's continuation) is run by the window that produced it,
|
|
7
|
+
* lease or not, for as long as that window is on the conversation.
|
|
8
|
+
*
|
|
9
|
+
* Delivery is at least once; the proof already in the conversation (`[Trigger <id>] ` for the clock's
|
|
10
|
+
* and a server's rows, the row's own text after its hand-over stamp for everything else: `landed`) is
|
|
11
|
+
* what makes a second delivery a no-op, and a row leaves the file only once that proof is in the file. A row that fails
|
|
12
|
+
* waits out a backoff and blocks nothing behind it; at three failures it is a dead letter: an inbox
|
|
13
|
+
* finding first, then an audit line, then the removal, so a crash in between loses no row.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import type { Inbox } from "./inbox.ts";
|
|
18
|
+
import { redact } from "./redact.ts";
|
|
19
|
+
import { type LeaseHolder, type LeaseStore, type ProcessProbe, type SessionLease, holderAlive, holderDelivers, systemProbe } from "./lease.ts";
|
|
20
|
+
import { withinProject } from "./paths.ts";
|
|
21
|
+
import { type ImageContent, type RequestMode, type RequestStore, type SessionRequest, imageRef } from "./requests.ts";
|
|
22
|
+
import type { LockOptions } from "./lock.ts";
|
|
23
|
+
import { stamp } from "./schedule.ts";
|
|
24
|
+
import { type LoopJob, newId } from "./store.ts";
|
|
25
|
+
import { type TriggerStore, checkDecidedFinding } from "./triggers.ts";
|
|
26
|
+
|
|
27
|
+
/** Failures of one row before it is a dead letter. */
|
|
28
|
+
export const DEAD_AFTER_ATTEMPTS = 3;
|
|
29
|
+
/** The wait after the first, second and later failures. */
|
|
30
|
+
export const BACKOFF_MS: readonly number[] = [60_000, 5 * 60_000, 30 * 60_000];
|
|
31
|
+
/** How much of a dead row's text the finding carries. */
|
|
32
|
+
export const DEAD_LETTER_CHARS = 400;
|
|
33
|
+
|
|
34
|
+
export function backoffFor(attempts: number): number {
|
|
35
|
+
return BACKOFF_MS[Math.min(Math.max(attempts, 1), BACKOFF_MS.length) - 1];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The mode a source gets (design §6.2): the producer writes it, the executor recomputes it. Attended
|
|
40
|
+
* rows run as the person's own turns; unattended ones under the row's declared tools and the guard;
|
|
41
|
+
* `none` asks for no model call of its own (a chat busy with a turn is still woken to see it, as a
|
|
42
|
+
* follow-up is: docs/design.md decision 9).
|
|
43
|
+
*/
|
|
44
|
+
export function modeOf(kind: SessionRequest["kind"], source: SessionRequest["source"], wake?: SessionRequest["wake"]): RequestMode {
|
|
45
|
+
if (kind !== "input") return "none";
|
|
46
|
+
if (source.startsWith("rule:")) return "none";
|
|
47
|
+
if (source.startsWith("push:")) return wake === "turn" ? "unattended" : "none";
|
|
48
|
+
if (source.startsWith("cron:")) return "unattended";
|
|
49
|
+
return "attended";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A rule's result as the row its creating conversation gets: an `append` with no model call, its trace id the proof of delivery. */
|
|
53
|
+
export function ruleResultRow(sessionId: string, content: string, trigger: { traceId: string; sourceLabel: string; cwd?: string }, createdBy: SessionRequest["created_by"], now = Date.now()): SessionRequest {
|
|
54
|
+
const source = `rule:${trigger.sourceLabel}` as const;
|
|
55
|
+
return { v: 2, id: trigger.traceId, session_id: sessionId, kind: "input", source, mode: modeOf("input", source, "append"), wake: "append", payload: { text: content, cwd: trigger.cwd ?? "" }, created_at: stamp(now), created_by: createdBy, attempts: 0 };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The run id of a plain job's slot: `run-` plus 32 hex of a hash of `jobId|slot`, where the slot is
|
|
60
|
+
* the due time the schedule owes, or the moment `/cron run` asked. Derived, not minted, so every
|
|
61
|
+
* producer of one slot (a window's clock, the same clock after a crash between the row and the
|
|
62
|
+
* stamps) writes the same id, and `appendIfAbsent` makes the second a no-op.
|
|
63
|
+
*/
|
|
64
|
+
export function plainRunId(jobId: string, slot: string): string {
|
|
65
|
+
return `run-${createHash("sha256").update(`${jobId}|${slot}`).digest("hex").slice(0, 32)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The collapse key a plain job's rows share: while one is queued, a later slot is spent without a new row. */
|
|
69
|
+
export const plainCollapseKey = (jobId: string): `cron:${string}` => `cron:${jobId}`;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A plain job's prompt as the row its conversation runs as a turn: the job's declaration (tools,
|
|
73
|
+
* model, thinking) rides along, so the executor runs it as the job asked, not as the chat is set.
|
|
74
|
+
* `source` is `cron:` for the clock and `cron-run:` for `/cron run` typed in the conversation.
|
|
75
|
+
*/
|
|
76
|
+
export function plainJobRow(job: Pick<LoopJob, "id" | "sessionId" | "prompt" | "cwd" | "model" | "thinking" | "tools" | "builtinTools">, id: string, source: `cron:${string}` | `cron-run:${string}`, note: string, createdBy: SessionRequest["created_by"], now = Date.now()): SessionRequest {
|
|
77
|
+
return {
|
|
78
|
+
v: 2,
|
|
79
|
+
id,
|
|
80
|
+
session_id: job.sessionId ?? "",
|
|
81
|
+
kind: "input",
|
|
82
|
+
source,
|
|
83
|
+
mode: modeOf("input", source, "turn"),
|
|
84
|
+
wake: "turn",
|
|
85
|
+
payload: { text: `[Trigger ${id}] ${job.prompt}${note}`, cwd: job.cwd, model: job.model, thinking: job.thinking, tools: job.tools, builtin_tools: job.builtinTools },
|
|
86
|
+
collapse_key: plainCollapseKey(job.id),
|
|
87
|
+
created_at: stamp(now),
|
|
88
|
+
created_by: createdBy,
|
|
89
|
+
attempts: 0,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What every builder below takes from the session the row is addressed to. */
|
|
94
|
+
export interface RowAddress {
|
|
95
|
+
sessionId: string;
|
|
96
|
+
cwd: string;
|
|
97
|
+
createdBy: SessionRequest["created_by"];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The sources whose text starts with `[Trigger <id>] `, the proof of delivery read by that prefix:
|
|
102
|
+
* the clock's prompt, `/cron run`, a rule's result, a push. What the person hands over themselves (a
|
|
103
|
+
* claim, a recipe hand-off, `/new-trigger`) and a goal's continuation go as their own words, as in
|
|
104
|
+
* 0.22.8, and are proved as a person row is: by their text, after the stamp they were handed over at.
|
|
105
|
+
*/
|
|
106
|
+
export function carriesPrefix(source: SessionRequest["source"]): boolean {
|
|
107
|
+
return source.startsWith("cron:") || source.startsWith("cron-run:") || source.startsWith("rule:") || source.startsWith("push:");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A turn row of one of a window's sources; `text` carries the prefix when `carriesPrefix(source)`. */
|
|
111
|
+
function turnRow(id: string, source: SessionRequest["source"], text: string, to: RowAddress, collapseKey: string | undefined, now: number): SessionRequest {
|
|
112
|
+
return {
|
|
113
|
+
v: 2,
|
|
114
|
+
id,
|
|
115
|
+
session_id: to.sessionId,
|
|
116
|
+
kind: "input",
|
|
117
|
+
source,
|
|
118
|
+
mode: modeOf("input", source, "turn"),
|
|
119
|
+
wake: "turn",
|
|
120
|
+
payload: { text, cwd: to.cwd },
|
|
121
|
+
...(collapseKey === undefined ? {} : { collapse_key: collapseKey }),
|
|
122
|
+
created_at: stamp(now),
|
|
123
|
+
created_by: to.createdBy,
|
|
124
|
+
attempts: 0,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* An MCP push a server delivers as `inject_and_run`: one turn in the window that has the server.
|
|
130
|
+
* `prompt` already starts with `[Trigger <traceId>] ` (src/trigger-runtime.ts), so the trace id is
|
|
131
|
+
* the row id and no second prefix is added. Pushes of one event kind collapse on the trigger's own
|
|
132
|
+
* idempotency key, without the `mcp:<server>:` the key repeats from the source.
|
|
133
|
+
*/
|
|
134
|
+
export function pushRunRow(prompt: string, trigger: { traceId: string; idempotencyKey: string; source: { kind: "mcp"; serverName: string } | { kind: "local"; subkind: string } }, to: RowAddress, now = Date.now()): SessionRequest {
|
|
135
|
+
const server = trigger.source.kind === "mcp" ? trigger.source.serverName : trigger.source.subkind;
|
|
136
|
+
const ownPrefix = `mcp:${server}:`;
|
|
137
|
+
const key = trigger.idempotencyKey.startsWith(ownPrefix) ? trigger.idempotencyKey.slice(ownPrefix.length) : trigger.idempotencyKey;
|
|
138
|
+
return turnRow(trigger.traceId, `push:${server}`, prompt, to, `push:${server}:${key}`, now);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The id of a goal's continuation: `goal-` plus 32 hex of a hash of the goal's condition, the
|
|
143
|
+
* iteration the continuation is and the stamp of the decision that asked for it. Derived, so the
|
|
144
|
+
* same continuation written twice is one row; the stamp keeps a goal set again with the same
|
|
145
|
+
* condition from finding its predecessor's `[Trigger goal-…]` in the conversation and never running.
|
|
146
|
+
*/
|
|
147
|
+
export function goalRunId(goal: { condition: string; iterations: number; updatedAt: string }): string {
|
|
148
|
+
return `goal-${createHash("sha256").update(`${goal.condition}|${goal.iterations}|${goal.updatedAt}`).digest("hex").slice(0, 32)}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A `/goal` continuation as the row its session runs as a turn: attended, the person's own goal (design O3), in the evaluator's words. */
|
|
152
|
+
export function goalRow(prompt: string, goal: { condition: string; iterations: number; updatedAt: string }, to: RowAddress, now = Date.now()): SessionRequest {
|
|
153
|
+
return turnRow(goalRunId(goal), "goal", prompt, to, "goal", now);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `/inbox claim`: the finding as the turn that addresses it. A fresh id per claim; one queued hand-off per finding. */
|
|
157
|
+
export function inboxClaimRow(prompt: string, entryId: string, to: RowAddress, now = Date.now()): SessionRequest {
|
|
158
|
+
return turnRow(newId("claim"), `inbox:${entryId}`, prompt, to, `inbox:${entryId}`, now);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A claim from the browser page: the row `/inbox claim` writes, signed by the page server and born
|
|
163
|
+
* left (`created_by.left`). The server lives on and never runs a turn, so a row it signed and stayed
|
|
164
|
+
* with would wait for it for ever; left at birth, the row is the holder's to run, as a terminal's
|
|
165
|
+
* claim is once its window moves on (`releaseLocalRows`), and nobody's `mine`, so a pi that only
|
|
166
|
+
* views the conversation never runs it beside the holder. Its mode stays attended: a holder believes
|
|
167
|
+
* that only once the page rings it for the row, and otherwise runs it capped as unattended.
|
|
168
|
+
*/
|
|
169
|
+
export function pageClaimRow(prompt: string, entryId: string, to: RowAddress, now = Date.now()): SessionRequest {
|
|
170
|
+
const row = inboxClaimRow(prompt, entryId, to, now);
|
|
171
|
+
return { ...row, created_by: { ...to.createdBy, left: stamp(now) } };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Whether `row` is a claim the page queued (`pageClaimRow`) and no holder has handed over yet. */
|
|
175
|
+
export function isPageClaim(row: SessionRequest): boolean {
|
|
176
|
+
return row.kind === "input" && row.source.startsWith("inbox:") && row.created_by.instance === PAGE_INSTANCE && row.created_by.left !== undefined && row.handed_at === undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The `created_by.instance` the page server signs its claims with. */
|
|
180
|
+
export const PAGE_INSTANCE = "page";
|
|
181
|
+
|
|
182
|
+
/** What a recipe hands to the chat: the tracker description `/recipe add` needs, or a merge conflict `/recipe update` left. */
|
|
183
|
+
export type RecipeHandOff = "tracker" | "merge";
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A recipe's hand-off to the chat: one queued at a time per recipe and per kind, so a merge conflict
|
|
187
|
+
* is not swallowed by the tracker description still waiting, which says nothing about it.
|
|
188
|
+
*/
|
|
189
|
+
export function recipeRow(prompt: string, recipe: string, kind: RecipeHandOff, to: RowAddress, now = Date.now()): SessionRequest {
|
|
190
|
+
return turnRow(newId("recipe"), `recipe:${recipe}`, prompt, to, `recipe:${recipe}:${kind}`, now);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** `/new-trigger <request>`: the turn that turns the request into a rule. Every one is its own. */
|
|
194
|
+
export function newTriggerRow(prompt: string, to: RowAddress, now = Date.now()): SessionRequest {
|
|
195
|
+
return turnRow(newId("nt"), "new-trigger", prompt, to, undefined, now);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* What the person typed into the browser page (design §5): a turn in their own words, with no
|
|
200
|
+
* `[Trigger]` prefix, so the text itself is the proof of delivery (`landed`). The id is the page's
|
|
201
|
+
* UUID for that send, so a retried send is one message. `created_by` is the page's web.mjs: the row
|
|
202
|
+
* is sent only while that process lives (`decideDrain`), and offered by the next page otherwise.
|
|
203
|
+
* The row names its images by digest; the bytes go beside the queue (`RequestStore.append`).
|
|
204
|
+
*/
|
|
205
|
+
export function personRow(id: string, text: string, images: ImageContent[] | undefined, to: RowAddress, now = Date.now()): SessionRequest {
|
|
206
|
+
return {
|
|
207
|
+
v: 2,
|
|
208
|
+
id,
|
|
209
|
+
session_id: to.sessionId,
|
|
210
|
+
kind: "input",
|
|
211
|
+
source: "person",
|
|
212
|
+
mode: modeOf("input", "person", "turn"),
|
|
213
|
+
wake: "turn",
|
|
214
|
+
payload: { text, ...(images?.length ? { images: images.map(imageRef) } : {}), cwd: to.cwd },
|
|
215
|
+
created_at: stamp(now),
|
|
216
|
+
created_by: to.createdBy,
|
|
217
|
+
attempts: 0,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* What a produced row said that decides its cap, as a hex digest: a change to any of it in the file
|
|
223
|
+
* is not the producer's. The text, the images (type and the digest of the bytes: a swapped image is
|
|
224
|
+
* another message, and `RequestStore.images` refuses bytes that do not match the digest), the
|
|
225
|
+
* declared tools. The page sends it with `/pi-loops ring <id> <fingerprint>` over pi's rpc stdin, a
|
|
226
|
+
* channel no model tool can write to, so the window believes the row's attended mode only when the
|
|
227
|
+
* row in the file still has this fingerprint (`ringProduced`).
|
|
228
|
+
*/
|
|
229
|
+
export function requestFingerprint(row: SessionRequest): string {
|
|
230
|
+
const images = row.payload?.images?.map((image) => [image.mimeType, image.sha256]) ?? null;
|
|
231
|
+
return createHash("sha256").update(JSON.stringify([row.kind, row.source, row.wake ?? null, row.payload?.text ?? "", images, row.payload?.tools ?? null, row.payload?.builtin_tools ?? null])).digest("hex").slice(0, 32);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* `/pi-loops ring <id> <fingerprint>`: the queued row with that id is recorded as produced by this
|
|
236
|
+
* window (`produced`) only when the row now in the file has exactly that fingerprint. Returns whether
|
|
237
|
+
* it was; a ring for a row that is gone, or that says something else now, records nothing.
|
|
238
|
+
*/
|
|
239
|
+
export function ringProduced(rows: readonly SessionRequest[], id: string, fingerprint: string, produced: (row: SessionRequest) => void): boolean {
|
|
240
|
+
const row = rows.find((r) => r.id === id);
|
|
241
|
+
if (!row || requestFingerprint(row) !== fingerprint) return false;
|
|
242
|
+
produced(row);
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* `pi-loops abort` (`cli`), `/triggers abort` (`window`) or the page's Stop this run (`person`): stop
|
|
248
|
+
* the turn `target` names, in whichever process holds the conversation. A row, so it outlives a
|
|
249
|
+
* socket that does not answer; no payload, the target is the whole request. `{id}` names a row's
|
|
250
|
+
* turn; `{current, started_before}` names the turn running when the stop was asked for, so a late one
|
|
251
|
+
* never stops the next turn.
|
|
252
|
+
*/
|
|
253
|
+
export function stopRow(sessionId: string, target: NonNullable<SessionRequest["target"]>, source: SessionRequest["source"], createdBy: SessionRequest["created_by"], now = Date.now()): SessionRequest {
|
|
254
|
+
return { v: 2, id: newId("stop"), session_id: sessionId, kind: "stop", source, mode: modeOf("stop", source), target, created_at: stamp(now), created_by: createdBy, attempts: 0 };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Take a queued row out before it runs. One pi already holds is not cancelled: a stop aborts it as it starts. */
|
|
258
|
+
export function cancelRow(sessionId: string, target: { id: string }, source: SessionRequest["source"], createdBy: SessionRequest["created_by"], now = Date.now()): SessionRequest {
|
|
259
|
+
return { v: 2, id: newId("cancel"), session_id: sessionId, kind: "cancel", source, mode: modeOf("cancel", source), target, created_at: stamp(now), created_by: createdBy, attempts: 0 };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export type HeldTurnMatch = { kind: "turn"; sessionId: string; runId: string } | { kind: "ambiguous"; matches: string[] };
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The held turn `ref` names, a run id or a prefix of one: the conversation the background process
|
|
266
|
+
* holds for it and the run's full id, read from the leases. A dead process's lease names no turn,
|
|
267
|
+
* nor does one held for a write (`busy` is a promotion's, not a `run-` id). A prefix of more than
|
|
268
|
+
* one turn names none: the matches, for the person to pick from.
|
|
269
|
+
*/
|
|
270
|
+
export function heldTurnFor(leases: readonly SessionLease[], ref: string, probe?: ProcessProbe): HeldTurnMatch | undefined {
|
|
271
|
+
const turns = leases.flatMap((lease) => {
|
|
272
|
+
const holder = lease.holder;
|
|
273
|
+
const runId = holder?.busy?.runId;
|
|
274
|
+
if (!holder || runId === undefined || !runId.startsWith("run-") || holder.kind !== "host" || !holderAlive(holder, probe)) return [];
|
|
275
|
+
return [{ sessionId: lease.sessionId, runId }];
|
|
276
|
+
});
|
|
277
|
+
const exact = turns.find((t) => t.runId === ref);
|
|
278
|
+
if (exact) return { kind: "turn", ...exact };
|
|
279
|
+
const matches = turns.filter((t) => t.runId.startsWith(ref));
|
|
280
|
+
if (matches.length === 1) return { kind: "turn", ...matches[0] };
|
|
281
|
+
if (matches.length > 1) return { kind: "ambiguous", matches: matches.map((t) => t.runId).sort() };
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The sources a person types into the window itself: `/inbox claim`, a recipe command, `/new-trigger`. */
|
|
286
|
+
function typedInWindow(source: SessionRequest["source"]): boolean {
|
|
287
|
+
return source.startsWith("inbox:") || source.startsWith("recipe:") || source === "new-trigger";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The sources a person types themselves, in the window the row is for or in the browser page: among
|
|
292
|
+
* the turn rows, these take the lane first, as the person's own message would. `/cron run` is typed
|
|
293
|
+
* too, in the job's own words with the prefix the clock's prompt has.
|
|
294
|
+
*/
|
|
295
|
+
export function typedByPerson(source: SessionRequest["source"]): boolean {
|
|
296
|
+
return source === "person" || source.startsWith("cron-run:") || typedInWindow(source);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* A row that runs in the process that produced it, whatever that process holds: what the person
|
|
301
|
+
* typed into the window (`typedInWindow`) and a goal's continuation. Their turn belongs in the window
|
|
302
|
+
* the person is looking at, and the goal's state is that window's; another executor runs one only
|
|
303
|
+
* once its producer is gone. The background process never does (src/host-runtime.ts admits no
|
|
304
|
+
* attended row). A `person` row is not local: its producer is the page's web.mjs, which runs nothing;
|
|
305
|
+
* the holder runs it (design §5, O2).
|
|
306
|
+
*/
|
|
307
|
+
export function isLocal(row: SessionRequest): boolean {
|
|
308
|
+
return row.kind === "input" && (typedInWindow(row.source) || row.source === "goal");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The process a row was written by, as `created_by` names it: the pid, and its start time when the writer had one. */
|
|
312
|
+
export interface Producer {
|
|
313
|
+
pid: number;
|
|
314
|
+
procStart?: string;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Whether `by` is the process `self`, started when `self` was: a reused pid is not the producer. */
|
|
318
|
+
export function sameProducer(by: Producer, self: Producer): boolean {
|
|
319
|
+
return by.pid === self.pid && (by.procStart === undefined || self.procStart === undefined || by.procStart === self.procStart);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The continuations `/goal clear` and a new `/goal` drop, since they belong to the condition being
|
|
324
|
+
* replaced: every queued `goal` row of this instance, and every one whose producer is gone (a window
|
|
325
|
+
* that quit mid-goal), which would otherwise be the new goal's first turn and, through the shared
|
|
326
|
+
* collapse key, swallow its own continuations. A live other window's rows are its goal's, not ours.
|
|
327
|
+
*/
|
|
328
|
+
export function dropContinuations(store: RequestStore, sessionId: string, self: Producer, alive: (row: SessionRequest) => boolean): string[] {
|
|
329
|
+
const stale = store.list(sessionId).filter((row) => row.kind === "input" && row.source === "goal" && (sameProducer(row.created_by, self) || !alive(row)));
|
|
330
|
+
if (stale.length) store.remove(sessionId, stale.map((row) => row.id));
|
|
331
|
+
return stale.map((row) => row.id);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* A window leaving a conversation (`/new`, `/resume`, quitting) with local rows of its own still
|
|
336
|
+
* queued marks them left, so the holder runs them: the pid stays alive, bound elsewhere, and the
|
|
337
|
+
* pid check alone would have them wait on it for as long as pi runs. A row whose proof is already in
|
|
338
|
+
* this window's conversation ran here and is removed instead: a holder reads its own copy of the
|
|
339
|
+
* conversation, which need not show what this window wrote, and would run it a second time.
|
|
340
|
+
* Returns the ids marked.
|
|
341
|
+
*/
|
|
342
|
+
export function releaseLocalRows(store: RequestStore, sessionId: string, self: Producer, deliveredHere: (id: string) => boolean, now = Date.now()): string[] {
|
|
343
|
+
const mine = store.list(sessionId).filter((row) => isLocal(row) && row.created_by.left === undefined && sameProducer(row.created_by, self));
|
|
344
|
+
const ran = mine.filter((row) => deliveredHere(row.id));
|
|
345
|
+
if (ran.length) store.remove(sessionId, ran.map((row) => row.id));
|
|
346
|
+
const left = mine.filter((row) => !deliveredHere(row.id));
|
|
347
|
+
for (const row of left) store.update(sessionId, row.id, { created_by: { ...row.created_by, left: stamp(now) } });
|
|
348
|
+
return left.map((row) => row.id);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Queue `row` unless a row with its id, or with its collapse key, is already queued: collapse, never
|
|
353
|
+
* replay (docs/design.md decision 6). A queued row whose proof `delivered` already finds in the
|
|
354
|
+
* conversation is done, not queued: the goal's next continuation is written while the last one is
|
|
355
|
+
* still in the file, waiting for the drain that removes it. Returns whether it was written.
|
|
356
|
+
*/
|
|
357
|
+
export function enqueue(store: RequestStore, row: SessionRequest, delivered: (id: string) => boolean = () => false, lock?: LockOptions): boolean {
|
|
358
|
+
return store.appendIfAbsent(row, { collapse: true, done: (queued) => delivered(queued.id) }, lock);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* How long a claim waits for a queue's lock while it holds the inbox's (`Inbox.decide`'s `before`):
|
|
363
|
+
* well inside the inbox lock's ten-second stale point, so a stuck queue lock makes the claim fail
|
|
364
|
+
* instead of letting another process break the inbox lock and lose its write under this rewrite.
|
|
365
|
+
*/
|
|
366
|
+
export const CLAIM_QUEUE_LOCK: LockOptions = { timeoutMs: 5_000 };
|
|
367
|
+
|
|
368
|
+
/** Where a producer's hand-over went, for the trigger audit: not written at all, in the chat already, or waiting in the file. */
|
|
369
|
+
export function handOverTarget(written: boolean, result: Pick<DrainResult, "delivered"> | undefined, id: string): "collapsed" | "chat" | "queued" {
|
|
370
|
+
if (!written) return "collapsed";
|
|
371
|
+
return result?.delivered.includes(id) ? "chat" : "queued";
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export const queuedTriggeredTurn = (runId: string): string => `queued triggered turn (trace ${runId.slice(0, 8)}) after the current one`;
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* What the window says when a turn row is queued while pi is busy: one line naming the row and its
|
|
378
|
+
* source, so a prompt waiting in the file is as visible as one pi used to queue under the editor. A
|
|
379
|
+
* push keeps the line it had; a goal continuation says nothing more than its own notice did. `name`
|
|
380
|
+
* is the plain job's, when the caller has it (the row names the job by id).
|
|
381
|
+
*/
|
|
382
|
+
export function waitingLine(source: SessionRequest["source"], id: string, name?: string): string | undefined {
|
|
383
|
+
if (source.startsWith("push:")) return queuedTriggeredTurn(id);
|
|
384
|
+
const job = jobOf(source);
|
|
385
|
+
if (job !== undefined) return `queued: cron ${name ?? job} runs after the current turn`;
|
|
386
|
+
if (source.startsWith("inbox:")) return "queued: /inbox claim runs after the current turn";
|
|
387
|
+
if (source.startsWith("recipe:")) return `queued: recipe ${source.slice("recipe:".length)} runs after the current turn`;
|
|
388
|
+
if (source === "new-trigger") return "queued: /new-trigger runs after the current turn";
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** What a window's hand-over needs: its store, whether a row's proof is in its conversation, its executor's `produced`, and its drain. */
|
|
393
|
+
export interface HandOverDeps {
|
|
394
|
+
store: RequestStore;
|
|
395
|
+
delivered(id: string): boolean;
|
|
396
|
+
produced(row: SessionRequest): void;
|
|
397
|
+
drain(): Promise<DrainResult | undefined>;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export interface HandOverOutcome {
|
|
401
|
+
/** False: a row with its id or its collapse key was already queued. */
|
|
402
|
+
written: boolean;
|
|
403
|
+
result: DrainResult | undefined;
|
|
404
|
+
/** The line to show when the row was written and waits, or when the claim it carried was already decided. */
|
|
405
|
+
line?: string;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* A prompt one of a window's sources has for the chat: queued as a row (collapsed when one with its
|
|
410
|
+
* key is already there), then delivered now when pi is idle; otherwise it waits in the file for the
|
|
411
|
+
* tick, the bell or `agent_settled`, and `line` says so once. For `/inbox claim`, the hand-off is
|
|
412
|
+
* queued under the inbox lock once the finding is found new, and the finding marked claimed after:
|
|
413
|
+
* a crash between the two leaves the finding `new` with its hand-off queued, where the reverse order
|
|
414
|
+
* left it claimed with nobody told.
|
|
415
|
+
*/
|
|
416
|
+
export async function handOver(deps: HandOverDeps, row: SessionRequest, claim?: { inbox: Pick<Inbox, "decide">; entryId: string }): Promise<HandOverOutcome> {
|
|
417
|
+
let written = false;
|
|
418
|
+
if (claim) {
|
|
419
|
+
// Queued under the inbox lock, after the finding is found still new (`Inbox.decide`): a claim
|
|
420
|
+
// decided elsewhere between the list and here (another window, the page, `pi-loops inbox`)
|
|
421
|
+
// queues nothing, so no holder can run a hand-off for a claim that lost.
|
|
422
|
+
const decided = await claim.inbox.decide(claim.entryId, "claimed", { by: row.session_id, before: () => void (written = enqueue(deps.store, row, deps.delivered, CLAIM_QUEUE_LOCK)) });
|
|
423
|
+
if (!("ok" in decided)) {
|
|
424
|
+
const ref = claim.entryId.slice(0, 12);
|
|
425
|
+
return { written: false, result: undefined, line: "missing" in decided ? `inbox: ${ref} is gone; nothing was sent` : `inbox: ${ref} was already ${decided.already}; nothing was sent` };
|
|
426
|
+
}
|
|
427
|
+
} else written = enqueue(deps.store, row, deps.delivered);
|
|
428
|
+
deps.produced(row);
|
|
429
|
+
const result = await deps.drain();
|
|
430
|
+
const waits = written && result !== undefined && !result.delivered.includes(row.id);
|
|
431
|
+
return { written, result, line: waits ? waitingLine(row.source, row.id) : undefined };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* One drain for when a wait ends on its own (`DrainResult.retryAt`): a timer per session, replaced
|
|
436
|
+
* and never stacked, so a terminal that holds a page's row drains it the moment the ring wait is
|
|
437
|
+
* over instead of at the next tick. `delayMs` at or below zero drains on the next turn of the loop.
|
|
438
|
+
*/
|
|
439
|
+
export function armRetry(timers: Map<string, ReturnType<typeof setTimeout>>, sessionId: string, delayMs: number, fn: () => void): void {
|
|
440
|
+
const old = timers.get(sessionId);
|
|
441
|
+
if (old !== undefined) clearTimeout(old);
|
|
442
|
+
const timer = setTimeout(() => {
|
|
443
|
+
timers.delete(sessionId);
|
|
444
|
+
fn();
|
|
445
|
+
}, Math.max(0, delayMs));
|
|
446
|
+
timer.unref?.();
|
|
447
|
+
timers.set(sessionId, timer);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* One `run` at a time, and at most one more after it: a burst of calls while one runs is one rerun,
|
|
452
|
+
* which reads what the running one read too early to see. The window's request watcher fires once
|
|
453
|
+
* per rewrite of the file, and every removal is a rewrite, so thirty rows used to be ninety drains,
|
|
454
|
+
* each a read of the file; nothing is lost by running fewer, since each drain reads the file afresh.
|
|
455
|
+
* `run` must not reject: it reports its own errors, as the drains do.
|
|
456
|
+
*/
|
|
457
|
+
export function coalesced(run: () => Promise<unknown>): () => void {
|
|
458
|
+
let running = false;
|
|
459
|
+
let again = false;
|
|
460
|
+
const go = (): void => {
|
|
461
|
+
if (running) {
|
|
462
|
+
again = true;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
running = true;
|
|
466
|
+
run()
|
|
467
|
+
.then(() => {
|
|
468
|
+
running = false;
|
|
469
|
+
if (again) {
|
|
470
|
+
again = false;
|
|
471
|
+
go();
|
|
472
|
+
}
|
|
473
|
+
})
|
|
474
|
+
.catch(() => undefined); // `run` reports its own errors; nothing is left to reject here
|
|
475
|
+
};
|
|
476
|
+
return go;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** A file as a quiet mark reads it: its size and mtime, or undefined for one that is not there. */
|
|
480
|
+
export type FileMark = { size: number; mtimeMs: number } | undefined;
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* What a closed conversation's queue was last found to need nothing for: the files the decision read
|
|
484
|
+
* (the conversation, jobs.json) as they were, the rows as they were, and until when the decision
|
|
485
|
+
* holds on its own (the earliest backoff that ends; Infinity when none does). While `stillQuiet`,
|
|
486
|
+
* the conversation is not read again: a row the background process cannot run (an attended row, a
|
|
487
|
+
* turn row until held turns ride rows) used to cost a full parse of the conversation on every tick,
|
|
488
|
+
* for as long as it waited there.
|
|
489
|
+
*/
|
|
490
|
+
export interface QuietMark {
|
|
491
|
+
key: string;
|
|
492
|
+
until: number;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function quietMark(files: readonly FileMark[], rows: readonly SessionRequest[], now: number): QuietMark {
|
|
496
|
+
const ends = rows.map((row) => (row.not_before === undefined ? NaN : Date.parse(row.not_before))).filter((at) => Number.isFinite(at) && at > now);
|
|
497
|
+
return { key: JSON.stringify([files, rows]), until: ends.length ? Math.min(...ends) : Infinity };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function stillQuiet(mark: QuietMark | undefined, files: readonly FileMark[], rows: readonly SessionRequest[], now: number): boolean {
|
|
501
|
+
return mark !== undefined && now < mark.until && mark.key === JSON.stringify([files, rows]);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export type PromotionTarget = { to: "row"; sessionId: string } | { to: "inbox"; sessionId?: string };
|
|
505
|
+
|
|
506
|
+
/** What `promotionTarget` asks about the conversations a rule's result could go to; `promotionView` reads it off the leases. */
|
|
507
|
+
export interface PromotionView {
|
|
508
|
+
/** A pi-loops 0.22 window has it open: that window never reads requests/. */
|
|
509
|
+
legacyOpen(sessionId: string): boolean;
|
|
510
|
+
/** A live window that delivers (src/lease.ts holderDelivers) holds it: the result lands in front of the person. */
|
|
511
|
+
open(sessionId: string): boolean;
|
|
512
|
+
/** pi-loops knows its file and the file is there: a row for it is appended by the background process. */
|
|
513
|
+
known(sessionId: string): boolean;
|
|
514
|
+
/** The conversations live windows hold in the rule's project, each with its holder's pid. */
|
|
515
|
+
openInProject(): Array<{ sessionId: string; pid: number }>;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Where a rule's result goes, decided the same way by a window and by the background process
|
|
520
|
+
* (owner decision, 2026-11-02): the conversation that created the rule while a live window holds it;
|
|
521
|
+
* else the conversation a live window holds in the rule's project, lowest pid first so every process
|
|
522
|
+
* computes the same answer (0.22.8's cwd owner); else the creating conversation, closed, which the
|
|
523
|
+
* background process appends to; else the inbox, marked for the creating conversation when there is
|
|
524
|
+
* one. A rule with no creating conversation (imported, or made by a loop's run) starts at the
|
|
525
|
+
* project's open window. A conversation a 0.22 window has open goes to the inbox, marked, as before.
|
|
526
|
+
*/
|
|
527
|
+
export function promotionTarget(forSession: string | undefined, view: PromotionView): PromotionTarget {
|
|
528
|
+
if (forSession !== undefined) {
|
|
529
|
+
if (view.legacyOpen(forSession)) return { to: "inbox", sessionId: forSession };
|
|
530
|
+
if (view.open(forSession)) return { to: "row", sessionId: forSession };
|
|
531
|
+
}
|
|
532
|
+
const window = view
|
|
533
|
+
.openInProject()
|
|
534
|
+
.filter((w) => !view.legacyOpen(w.sessionId))
|
|
535
|
+
.sort((a, b) => a.pid - b.pid || a.sessionId.localeCompare(b.sessionId))[0];
|
|
536
|
+
if (window) return { to: "row", sessionId: window.sessionId };
|
|
537
|
+
if (forSession !== undefined && view.known(forSession)) return { to: "row", sessionId: forSession };
|
|
538
|
+
return { to: "inbox", ...(forSession === undefined ? {} : { sessionId: forSession }) };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The view `promotionTarget` decides on, read from the leases: a live `terminal` holder that delivers
|
|
543
|
+
* is an open window; a lease whose `cwd` is `cwd` or inside it is in the rule's project (as 0.22.8's
|
|
544
|
+
* `chooseCwdOwner` read presence). No `cwd`, no project: only the creating conversation can take it.
|
|
545
|
+
*/
|
|
546
|
+
export function promotionView(leases: Pick<LeaseStore, "read" | "list">, cwd: string | undefined, legacyOpen: (sessionId: string) => boolean, probe: ProcessProbe = systemProbe): PromotionView {
|
|
547
|
+
const windowOf = (lease: SessionLease | undefined): LeaseHolder | undefined => {
|
|
548
|
+
const holder = lease?.holder;
|
|
549
|
+
return holder && holder.kind === "terminal" && holderAlive(holder, probe) && holderDelivers(holder) ? holder : undefined;
|
|
550
|
+
};
|
|
551
|
+
return {
|
|
552
|
+
legacyOpen,
|
|
553
|
+
open: (id) => windowOf(leases.read(id)) !== undefined,
|
|
554
|
+
known: (id) => {
|
|
555
|
+
const file = leases.read(id)?.sessionFile;
|
|
556
|
+
return !!file && fs.existsSync(file);
|
|
557
|
+
},
|
|
558
|
+
openInProject: () => {
|
|
559
|
+
if (cwd === undefined) return [];
|
|
560
|
+
return leases.list().flatMap((lease) => {
|
|
561
|
+
const holder = windowOf(lease);
|
|
562
|
+
return holder && lease.cwd && withinProject(cwd, lease.cwd) ? [{ sessionId: lease.sessionId, pid: holder.pid }] : [];
|
|
563
|
+
});
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** What a row's proof of delivery is read from: the conversation, as `landed` asks it. */
|
|
569
|
+
export interface ConversationView {
|
|
570
|
+
/** Whether `[Trigger <id>] ` is in it (`conversationHas`). */
|
|
571
|
+
has(id: string): boolean;
|
|
572
|
+
/** How many user messages whose text is `text`'s (`personTextMatches`) it holds, written at or after `since` (`conversationPersonEntries`). */
|
|
573
|
+
personEntries(text: string, since: string): number;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Whether `row`'s delivery is in the conversation (design §2.2). A row of a `carriesPrefix` source
|
|
578
|
+
* carries its proof in its text: `[Trigger <id>] `. Every other row's proof is a user message whose
|
|
579
|
+
* text starts with the row's (pi appends a note to the text when it resizes or converts an image),
|
|
580
|
+
* written at or after `handed_at`, the stamp the holder puts on the row just before each hand-over.
|
|
581
|
+
* One lane hands over one such row at a time, so an earlier message with the same words, or another
|
|
582
|
+
* row's, is older than the stamp and proves nothing; a row never handed over has no stamp and is
|
|
583
|
+
* never done.
|
|
584
|
+
*/
|
|
585
|
+
export function landed(row: SessionRequest, conversation: ConversationView): boolean {
|
|
586
|
+
if (carriesPrefix(row.source)) return conversation.has(row.id);
|
|
587
|
+
return row.handed_at !== undefined && conversation.personEntries(row.payload?.text ?? "", row.handed_at) > 0;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* pi's notes on the images of a user message, appended to its text: `[Image: original …]` for one it
|
|
592
|
+
* resized, `[Image omitted …]` for one it could not decode, `[Image converted …]` for a format it
|
|
593
|
+
* changed. Stripped before a row with no text of its own is matched: such a row is proved only by a
|
|
594
|
+
* message that says nothing else, not by any message that follows it.
|
|
595
|
+
*/
|
|
596
|
+
export function stripImageNotes(text: string): string {
|
|
597
|
+
return text.replace(TRAILING_IMAGE_NOTES, "").trim();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** The three notes pi writes (`utils/image-process.js`), and only at the end: a person's own `[Image: …]` stays theirs. */
|
|
601
|
+
const IMAGE_NOTE = String.raw`\[Image: original \d+x\d+, displayed at \d+x\d+\. Multiply coordinates by [\d.]+ to map to original image\.\]|\[Image omitted: [^\]]*\]|\[Image converted from [^\]]* to [^\]]*\.\]`;
|
|
602
|
+
const TRAILING_IMAGE_NOTES = new RegExp(String.raw`(?:\s*(?:${IMAGE_NOTE}))+\s*$`);
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Whether a user message's text is a person row's: it starts with the row's text (pi appends its
|
|
606
|
+
* image notes after it), or, for a row with no text, it has none of its own once those notes go.
|
|
607
|
+
*/
|
|
608
|
+
export function personTextMatches(messageText: string, rowText: string): boolean {
|
|
609
|
+
return rowText === "" ? stripImageNotes(messageText) === "" : messageText.startsWith(rowText);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const textOf = (content: unknown): string | undefined => {
|
|
613
|
+
if (typeof content === "string") return content;
|
|
614
|
+
if (!Array.isArray(content)) return undefined;
|
|
615
|
+
const first = content.find((part) => !!part && typeof part === "object" && (part as { type?: unknown }).type === "text");
|
|
616
|
+
return first && typeof (first as { text?: unknown }).text === "string" ? (first as { text: string }).text : undefined;
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
/** The user messages among `entries` whose text starts with `text`, written at or after `since` (pi's `timestamp` on the entry). */
|
|
620
|
+
export function conversationPersonEntries(entries: readonly unknown[], text: string, since: string): number {
|
|
621
|
+
const floor = Date.parse(since);
|
|
622
|
+
return entries.filter((entry) => {
|
|
623
|
+
if (!entry || typeof entry !== "object") return false;
|
|
624
|
+
const value = entry as { type?: unknown; timestamp?: unknown; message?: { role?: unknown; content?: unknown } };
|
|
625
|
+
if (value.type !== "message" || value.message?.role !== "user" || !personTextMatches(textOf(value.message.content) ?? "", text)) return false;
|
|
626
|
+
const at = typeof value.timestamp === "string" ? Date.parse(value.timestamp) : Number.NaN;
|
|
627
|
+
return Number.isNaN(at) ? false : at >= floor;
|
|
628
|
+
}).length;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** `conversationHas` and `conversationPersonEntries` over one list of entries. */
|
|
632
|
+
export function conversationView(entries: () => readonly unknown[]): ConversationView {
|
|
633
|
+
return { has: (id) => conversationHas(entries(), id), personEntries: (text, since) => conversationPersonEntries(entries(), text, since) };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Whether `[Trigger <id>] ` is in the conversation: a user message or a custom message that starts with it. */
|
|
637
|
+
export function conversationHas(entries: readonly unknown[], id: string): boolean {
|
|
638
|
+
const prefix = `[Trigger ${id}] `;
|
|
639
|
+
return entries.some((entry) => {
|
|
640
|
+
if (!entry || typeof entry !== "object") return false;
|
|
641
|
+
const value = entry as { type?: unknown; message?: { role?: unknown; content?: unknown }; content?: unknown };
|
|
642
|
+
if (value.type === "message" && value.message?.role === "user") return textOf(value.message.content)?.startsWith(prefix) ?? false;
|
|
643
|
+
if (value.type === "custom_message") return textOf(value.content)?.startsWith(prefix) ?? false;
|
|
644
|
+
return false;
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* A session file's entries, read and parsed only. `SessionManager.open` migrates an old file or
|
|
650
|
+
* fills an empty one, which is a write, and a process without the lease must not write there.
|
|
651
|
+
*/
|
|
652
|
+
export function sessionFileEntries(file: string): unknown[] {
|
|
653
|
+
let data: string;
|
|
654
|
+
try {
|
|
655
|
+
data = fs.readFileSync(file, "utf8");
|
|
656
|
+
} catch {
|
|
657
|
+
return []; // no file: nothing landed
|
|
658
|
+
}
|
|
659
|
+
return data.split("\n").flatMap((line) => {
|
|
660
|
+
try {
|
|
661
|
+
return line ? [JSON.parse(line)] : [];
|
|
662
|
+
} catch {
|
|
663
|
+
return []; // a torn line holds no entry
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** `conversationHas` over a session file on disk (pi's JSONL), for a process with no session open on it. */
|
|
669
|
+
export function sessionFileHas(file: string, id: string): boolean {
|
|
670
|
+
return conversationHas(sessionFileEntries(file), id);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* The modes each executor runs (design §6.3): a window every one, since the person is there to be
|
|
675
|
+
* asked; the background process no attended row, which waits for a window. The view carries the one
|
|
676
|
+
* for its kind, and `decideDrain` asks it after it has recomputed the row's mode itself.
|
|
677
|
+
*/
|
|
678
|
+
export const CEILING: Record<ExecutorView["kind"], (mode: RequestMode) => boolean> = { window: () => true, held: (mode) => mode !== "attended" };
|
|
679
|
+
|
|
680
|
+
export interface ExecutorView {
|
|
681
|
+
kind: "window" | "held";
|
|
682
|
+
/** Whether this executor runs rows of `mode` (`CEILING`); a row above it waits for an executor that does. */
|
|
683
|
+
ceiling(mode: RequestMode): boolean;
|
|
684
|
+
/**
|
|
685
|
+
* This executor's own reasons to leave a row for later, its mode apart (that is `ceiling`, and the
|
|
686
|
+
* engine recomputes the mode itself): the background process takes nothing while a held turn has
|
|
687
|
+
* the conversation's file, and no turn row until held turns ride rows.
|
|
688
|
+
*/
|
|
689
|
+
admits(row: SessionRequest): boolean;
|
|
690
|
+
/** window: `ctx.isIdle()`; the background process writes into a conversation nobody has open. */
|
|
691
|
+
idle: boolean;
|
|
692
|
+
running?: { requestId?: string; startedAt: string };
|
|
693
|
+
/** Handed to pi and not yet in the conversation: the derived claims. */
|
|
694
|
+
inFlight: ReadonlySet<string>;
|
|
695
|
+
/** Rows a stop named that pi then dropped unstarted (Esc): removed as cancelled, never offered again. */
|
|
696
|
+
stopped?: ReadonlySet<string>;
|
|
697
|
+
/** Rows whose turn ran under a message that lost their prefix: done, though the file cannot prove it. */
|
|
698
|
+
ran?: ReadonlySet<string>;
|
|
699
|
+
/**
|
|
700
|
+
* When each turn row in `inFlight` was handed to pi, for the ones pi has started no run since
|
|
701
|
+
* (`agent_start` clears them; an `input` or `message_start` seen meanwhile restarts the count).
|
|
702
|
+
* One still here while pi is idle `PERSON_SEND_GRACE_MS` later was refused: pi's `prompt()` throws
|
|
703
|
+
* before any run starts (compaction in progress, no model, no credentials), and an extension hears
|
|
704
|
+
* nothing of it.
|
|
705
|
+
*/
|
|
706
|
+
inFlightSince?: ReadonlyMap<string, number>;
|
|
707
|
+
/**
|
|
708
|
+
* Rows pi was handed and had dropped unwritten when it settled (Esc clears its queues; an `input`
|
|
709
|
+
* handler that rewrote the prefix away). Offered again, with an attempt counted: without the
|
|
710
|
+
* count, a prompt an extension rewrites on every turn would run for ever.
|
|
711
|
+
*/
|
|
712
|
+
dropped?: ReadonlySet<string>;
|
|
713
|
+
/**
|
|
714
|
+
* Whether this executor was rung for the row (`/pi-loops ring <id> <fingerprint>`) and the row
|
|
715
|
+
* still says what the ring said: its attended mode is believed. A fresh person row nobody rang for
|
|
716
|
+
* yet waits `PERSON_RING_WAIT_MS`, so the bell wins the race with the file watcher.
|
|
717
|
+
*/
|
|
718
|
+
believed?(row: SessionRequest): boolean;
|
|
719
|
+
connected(server: string): boolean;
|
|
720
|
+
/** Whether the process that wrote the row is still there: a local row waits for it, a person row is sent only for it. */
|
|
721
|
+
producerAlive(row: SessionRequest): boolean;
|
|
722
|
+
/** This executor is the row's producer, bound to its session: the one that runs a local row while it lives, and the page's pi for a person row the page rang it for. */
|
|
723
|
+
mine(row: SessionRequest): boolean;
|
|
724
|
+
/**
|
|
725
|
+
* Whether the conversation's live holder, another process, delivers the person rows addressed to
|
|
726
|
+
* it (src/lease.ts holderDelivers); undefined when nobody else holds it. `false` makes this
|
|
727
|
+
* executor run the person rows it was rung for (`mine`) in that holder's place: a terminal standing
|
|
728
|
+
* aside, or a `pi -p` run, holds the lease and hands nothing over, and the page's message would
|
|
729
|
+
* otherwise wait for ever, where 0.22 answered it from the page's own pi.
|
|
730
|
+
*/
|
|
731
|
+
holderDelivers?: boolean;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
export type Action =
|
|
735
|
+
| { do: "append"; row: SessionRequest; wakeAgent: boolean }
|
|
736
|
+
| { do: "turn"; row: SessionRequest }
|
|
737
|
+
| { do: "abort"; stop: SessionRequest }
|
|
738
|
+
| { do: "abortWhenStarted"; stop: SessionRequest; target: string }
|
|
739
|
+
| { do: "remove"; row: SessionRequest; why: "done" | "cancelled" | "stale" | "job-removed" | "refused" }
|
|
740
|
+
| { do: "dead"; row: SessionRequest; why: string }
|
|
741
|
+
/**
|
|
742
|
+
* pi did not write the row it was handed. An attempt is counted; at `DEAD_AFTER_ATTEMPTS` a pi-loops
|
|
743
|
+
* row is a dead letter. `retry` says when it is offered again: `never` for a person row (the page
|
|
744
|
+
* takes it back: a retry that did land would answer one prompt twice), `backoff` for a hand-over
|
|
745
|
+
* pi refused, `now` for one pi dropped (Esc), which the hold after a stop already paces.
|
|
746
|
+
*/
|
|
747
|
+
| { do: "fail"; row: SessionRequest; why: string; retry: "never" | "backoff" | "now" }
|
|
748
|
+
/** `until`: when the wait ends on its own (the ring wait), for a drain to be scheduled then. */
|
|
749
|
+
| { do: "wait"; row: SessionRequest; why: string; until?: number };
|
|
750
|
+
|
|
751
|
+
/** What the caller knows about the drain it asks for: a tick is the one drain that may judge a hand-over pi never answered. */
|
|
752
|
+
export interface DrainOptions {
|
|
753
|
+
tick?: boolean;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* How long a turn row handed to an idle pi may go without a run starting before it counts as
|
|
758
|
+
* refused. pi emits `agent_start` before its first provider call, so a slow first token is not
|
|
759
|
+
* this, and compaction is not idle (`isIdle` is false while it runs), so a run that compacts first
|
|
760
|
+
* is not either. The count restarts at every `input` and `message_start` pi emits meanwhile, and
|
|
761
|
+
* what is left to cover is pi's pre-run phase after its `input` event: the other extensions' input
|
|
762
|
+
* handlers and the credential check, which can go to the network, none of which pi reports. Five
|
|
763
|
+
* seconds covered `before_agent_start` handlers alone, and a tick landing after a slow credential
|
|
764
|
+
* check failed a send pi then ran. The name stays: src/web.mjs reads it for its follow-up ring.
|
|
765
|
+
*/
|
|
766
|
+
export const PERSON_SEND_GRACE_MS = 10_000;
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* How long a person row nobody rang for waits before it is handed over capped: the page appends
|
|
770
|
+
* then rings, and the file watcher fires on the append, so a drain can see the row before the bell
|
|
771
|
+
* that vouches for it. Past this, a row stays capped (a terminal answering a page's row, owner
|
|
772
|
+
* decision O2; a line written by hand), and the window asks before a dangerous command.
|
|
773
|
+
*/
|
|
774
|
+
export const PERSON_RING_WAIT_MS = 2_000;
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Whether applying `actions` writes anything: a row handed over, removed or buried. A drain that
|
|
778
|
+
* only waits leaves the file, the conversation and the lease as they were, so a holder that must
|
|
779
|
+
* take a lease to drain can see from this alone that there is nothing to take it for.
|
|
780
|
+
*/
|
|
781
|
+
export function changesSomething(actions: readonly Action[]): boolean {
|
|
782
|
+
return actions.some((a) => a.do === "append" || a.do === "turn" || a.do === "remove" || a.do === "dead" || a.do === "fail");
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* The input rows of a session in the order the engine offers them, which is the order the page
|
|
787
|
+
* lists them: file order, except that among the turn rows what the person typed takes the lane
|
|
788
|
+
* before what the clock or a server queued, as their own message would; `append` rows keep their
|
|
789
|
+
* place, so a result queued before the claim lands before it.
|
|
790
|
+
*/
|
|
791
|
+
export function queueOrder(rows: readonly SessionRequest[]): SessionRequest[] {
|
|
792
|
+
const turns = rows.filter((row) => row.kind === "input" && row.wake === "turn");
|
|
793
|
+
const turnOrder = [...turns.filter((row) => typedByPerson(row.source)), ...turns.filter((row) => !typedByPerson(row.source))];
|
|
794
|
+
return rows.filter((row) => row.kind === "input").map((row) => (row.wake === "turn" ? turnOrder.shift()! : row));
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export interface JobsView {
|
|
798
|
+
exists(id: string): boolean;
|
|
799
|
+
/** Whether the job is enabled: the clock's row of a disabled job waits, as the clock itself does. */
|
|
800
|
+
enabled(id: string): boolean;
|
|
801
|
+
/** Whether the session has a goal being pursued: a `goal` row without one is stale. */
|
|
802
|
+
goalActive(sessionId: string): boolean;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const jobOf = (source: string): string | undefined => (source.startsWith("cron:") ? source.slice("cron:".length) : source.startsWith("cron-run:") ? source.slice("cron-run:".length) : undefined);
|
|
806
|
+
|
|
807
|
+
/** A plain job's prompt waiting in its conversation's queue, as `/cron` and `cron_list` report it. */
|
|
808
|
+
export interface QueuedPrompt {
|
|
809
|
+
/** The row's `created_at`: when the prompt was queued. */
|
|
810
|
+
since: string;
|
|
811
|
+
/** Why it waits, when the row itself says: the backoff after a failed hand-over. */
|
|
812
|
+
reason?: string;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* The oldest queued row of a plain job among its conversation's `rows` (`cron:` from the clock,
|
|
817
|
+
* `cron-run:` from `/cron run`), or undefined when none of the job's is queued. The row a held turn
|
|
818
|
+
* is running stays in the file while it runs and is not queued: the job's `running` marker names
|
|
819
|
+
* it, and the listing already shows that. The reason is read from the row alone, so a listing costs
|
|
820
|
+
* one file read per conversation and no executor view.
|
|
821
|
+
*/
|
|
822
|
+
export function queuedPrompt(job: Pick<LoopJob, "id" | "running">, rows: readonly SessionRequest[], now: number): QueuedPrompt | undefined {
|
|
823
|
+
// Only a row the engine would run for this job: its own prefix, its source's mode, not dead. A row
|
|
824
|
+
// written into the file under a job's name is not that job's prompt, and the listing must not say so.
|
|
825
|
+
const row = rows.find((r) => r.kind === "input" && jobOf(r.source) === job.id && r.id !== job.running?.runId && r.payload?.text.startsWith(`[Trigger ${r.id}] `) && modeOf(r.kind, r.source, r.wake) === r.mode && r.attempts < DEAD_AFTER_ATTEMPTS);
|
|
826
|
+
if (!row) return undefined;
|
|
827
|
+
// Stamps from the file are re-written from what they parse to, never echoed: they reach a terminal and the model.
|
|
828
|
+
const created = Date.parse(row.created_at);
|
|
829
|
+
const since = Number.isFinite(created) ? stamp(created) : "?";
|
|
830
|
+
const until = row.not_before === undefined ? NaN : Date.parse(row.not_before);
|
|
831
|
+
if (!Number.isFinite(until) || until <= now) return { since };
|
|
832
|
+
// The error is ours (`redact`ed when written), so the listing may say why the row backs off.
|
|
833
|
+
return { since, reason: `${row.last_error ? `${row.last_error}; ` : ""}backing off until ${stamp(until)}` };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** `ms` as the largest whole unit a wait is read in: `12s`, `3m`, `2h`, `5d`; `?` for a stamp that did not parse. */
|
|
837
|
+
export function formatWait(ms: number): string {
|
|
838
|
+
if (!Number.isFinite(ms)) return "?";
|
|
839
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
840
|
+
if (s < 60) return `${s}s`;
|
|
841
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
842
|
+
if (s < 86_400) return `${Math.floor(s / 3600)}h`;
|
|
843
|
+
return `${Math.floor(s / 86_400)}d`;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Pure. Stops and cancels first, idle or busy; then the inputs, oldest first, one lane: a row in
|
|
848
|
+
* backoff, waiting for a server, handed over, above this executor's ceiling or not admitted here is
|
|
849
|
+
* skipped and blocks nothing; one whose mode is not its source's is refused and removed.
|
|
850
|
+
* `append` rows land at once while idle, with no turn; during a turn they go as a follow-up, which
|
|
851
|
+
* wakes the agent when it ends (docs/design.md decision 9). A `turn` row is handed over only while
|
|
852
|
+
* pi is idle and the lane is free, one per drain. Among the turn rows, what the person typed takes
|
|
853
|
+
* the lane before what the clock or a server queued, as their own message would; `append` rows keep
|
|
854
|
+
* their place, so a result queued before the claim lands before it. A local row (`isLocal`) is run by its
|
|
855
|
+
* producer while that process lives; every other executor skips it, judges nothing about it and
|
|
856
|
+
* counts no attempt.
|
|
857
|
+
*/
|
|
858
|
+
export function decideDrain(rows: readonly SessionRequest[], entries: ConversationView, jobs: JobsView, view: ExecutorView, now: number, opts: DrainOptions = {}): Action[] {
|
|
859
|
+
const actions: Action[] = [];
|
|
860
|
+
const queued = new Map(rows.filter((row) => row.kind === "input").map((row) => [row.id, row]));
|
|
861
|
+
const inputs = queueOrder(rows);
|
|
862
|
+
const cancelled = new Set<string>();
|
|
863
|
+
for (const row of rows) {
|
|
864
|
+
if (row.kind === "input") continue;
|
|
865
|
+
let done = false;
|
|
866
|
+
const target = row.target;
|
|
867
|
+
if (target && "id" in target) {
|
|
868
|
+
if (row.kind === "stop" && view.running?.requestId === target.id) {
|
|
869
|
+
actions.push({ do: "abort", stop: row });
|
|
870
|
+
done = true;
|
|
871
|
+
} else if (row.kind === "stop" && view.inFlight.has(target.id)) {
|
|
872
|
+
// pi holds it (the idle-check race): removing the row would not keep pi from running it.
|
|
873
|
+
actions.push({ do: "abortWhenStarted", stop: row, target: target.id });
|
|
874
|
+
done = true;
|
|
875
|
+
} else if (queued.has(target.id) && !view.inFlight.has(target.id) && !cancelled.has(target.id)) {
|
|
876
|
+
cancelled.add(target.id);
|
|
877
|
+
actions.push({ do: "remove", row: queued.get(target.id)!, why: "cancelled" });
|
|
878
|
+
done = true;
|
|
879
|
+
}
|
|
880
|
+
} else if (target && row.kind === "stop" && view.running && Date.parse(view.running.startedAt) < Date.parse(target.started_before)) {
|
|
881
|
+
// As times: each stamp carries its writer's offset, and two offsets do not order as strings.
|
|
882
|
+
actions.push({ do: "abort", stop: row });
|
|
883
|
+
done = true;
|
|
884
|
+
}
|
|
885
|
+
actions.push({ do: "remove", row, why: done ? "done" : "stale" });
|
|
886
|
+
}
|
|
887
|
+
let laneTaken = false;
|
|
888
|
+
for (const row of inputs) {
|
|
889
|
+
if (cancelled.has(row.id)) continue;
|
|
890
|
+
if (landed(row, entries)) {
|
|
891
|
+
actions.push({ do: "remove", row, why: "done" });
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
if (view.stopped?.has(row.id)) {
|
|
895
|
+
actions.push({ do: "remove", row, why: "cancelled" });
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (view.ran?.has(row.id)) {
|
|
899
|
+
actions.push({ do: "remove", row, why: "done" });
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
const wait = (why: string, until?: number) => actions.push({ do: "wait", row, why, ...(until === undefined ? {} : { until }) });
|
|
903
|
+
// Judged on a tick only: the drain that handed the row over, and the one `agent_settled` runs
|
|
904
|
+
// (pi defers a prompt sent while it emits that event until after the handler), both come
|
|
905
|
+
// before pi has had its chance. A bell from outside pi's event cycle (the page's ring) counts.
|
|
906
|
+
const since = view.inFlightSince?.get(row.id);
|
|
907
|
+
const refused = opts.tick && since !== undefined && view.idle && now - since >= PERSON_SEND_GRACE_MS;
|
|
908
|
+
if (row.source === "person") {
|
|
909
|
+
// pi refused it (see `inFlightSince`): the row keeps its error for the page to take back, and
|
|
910
|
+
// is never handed over again, since retrying would answer one prompt twice if it did land.
|
|
911
|
+
if (row.last_error !== undefined) {
|
|
912
|
+
wait(`pi refused it: ${row.last_error}`);
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
if (refused) {
|
|
916
|
+
actions.push({ do: "fail", row, why: "pi did not start a turn for it", retry: "never" });
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
} else if (refused) {
|
|
920
|
+
// A pi-loops row is proved by its prefix, or by its hand-over stamp and text, so offering it again is safe; one refusal must not
|
|
921
|
+
// hold the slot for ever, nor the job's later slots, which collapse onto a queued row.
|
|
922
|
+
actions.push({ do: "fail", row, why: "pi did not start a turn for it", retry: "backoff" });
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
// A person row pi dropped is offered again uncounted: a Stop put it back, and the next idle
|
|
926
|
+
// drain hands it over, as the message they queued would have gone.
|
|
927
|
+
if (row.source !== "person" && view.dropped?.has(row.id)) {
|
|
928
|
+
actions.push({ do: "fail", row, why: "pi dropped it before it was written", retry: "now" });
|
|
929
|
+
// The attempt that buries it: nothing below may hand it over as well.
|
|
930
|
+
if (row.attempts + 1 >= DEAD_AFTER_ATTEMPTS) continue;
|
|
931
|
+
}
|
|
932
|
+
if (row.attempts >= DEAD_AFTER_ATTEMPTS) {
|
|
933
|
+
actions.push({ do: "dead", row, why: `failed ${row.attempts} times${row.last_error ? `: ${row.last_error}` : ""}` });
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
// A person's message may be an image alone; everything else is text.
|
|
937
|
+
if (!row.payload?.text && !(row.source === "person" && row.payload?.images?.length)) {
|
|
938
|
+
actions.push({ do: "dead", row, why: "an input row without text" });
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
// The prefix is the proof of delivery: a row of such a source without it would be written again on
|
|
942
|
+
// every drain, with nothing to tell that it landed.
|
|
943
|
+
if (carriesPrefix(row.source) && !row.payload?.text.startsWith(`[Trigger ${row.id}] `)) {
|
|
944
|
+
actions.push({ do: "dead", row, why: "an input row without its [Trigger <id>] prefix" });
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
// The producer wrote the mode; the executor runs the row under the one its source gets, and
|
|
948
|
+
// nothing else. A line edited in the file, or written by another version, cannot widen what
|
|
949
|
+
// the turn may do: it is refused, and the audit says so.
|
|
950
|
+
if (modeOf(row.kind, row.source, row.wake) !== row.mode) {
|
|
951
|
+
actions.push({ do: "remove", row, why: "refused" });
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
const jobId = jobOf(row.source);
|
|
955
|
+
if (jobId !== undefined) {
|
|
956
|
+
// The jobs are read from a file that can be damaged (empty, written by a newer pi-loops): that
|
|
957
|
+
// costs the rows that name a job, and nothing else in the queue.
|
|
958
|
+
let job: { exists: boolean; enabled: boolean };
|
|
959
|
+
try {
|
|
960
|
+
const exists = jobs.exists(jobId);
|
|
961
|
+
job = { exists, enabled: exists && jobs.enabled(jobId) };
|
|
962
|
+
} catch (err: any) {
|
|
963
|
+
wait(`cannot read the jobs: ${redact(err?.message ?? String(err))}`);
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
if (!job.exists) {
|
|
967
|
+
actions.push({ do: "remove", row, why: "job-removed" });
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
// The clock's prompt waits with its clock; `/cron run` was asked for, and `/cron run` itself
|
|
971
|
+
// refuses a job that is disabled when it is typed.
|
|
972
|
+
if (!job.enabled && row.source.startsWith("cron:")) {
|
|
973
|
+
wait("its job is disabled");
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (isLocal(row) && !view.mine(row) && view.producerAlive(row)) {
|
|
978
|
+
wait("runs in the window that produced it");
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
// Judged by the executor that runs the row: the goal is that process's state.
|
|
982
|
+
if (row.source === "goal" && !jobs.goalActive(row.session_id)) {
|
|
983
|
+
actions.push({ do: "remove", row, why: "stale" });
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
if (!view.ceiling(row.mode)) {
|
|
987
|
+
wait(`${row.mode} rows wait for a window`);
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
if (!view.admits(row)) {
|
|
991
|
+
wait("not admitted by this executor");
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
if (row.not_before !== undefined && Date.parse(row.not_before) > now) {
|
|
995
|
+
wait(`backing off until ${row.not_before}`);
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
if (view.inFlight.has(row.id)) {
|
|
999
|
+
// A turn pi holds and has not written, of a row proved by its text, is the lane's: handed over too, a second row
|
|
1000
|
+
// with the same words would be proved by the first one's message. A prefixed row is proved by
|
|
1001
|
+
// its own prefix, and one pi silently dropped must not stall the rows behind it.
|
|
1002
|
+
if (row.wake === "turn" && !carriesPrefix(row.source)) laneTaken = true;
|
|
1003
|
+
wait("handed to pi, not yet in the conversation");
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
// A person's message is sent only for the page that wrote it (design §5); every other row
|
|
1007
|
+
// outlives its producer, and a local one is the holder's to run once the producer is gone.
|
|
1008
|
+
if (row.source === "person" && !view.producerAlive(row)) {
|
|
1009
|
+
wait("its producer is gone");
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
if (row.source === "person" && view.believed && !view.believed(row) && now - Date.parse(row.created_at) < PERSON_RING_WAIT_MS) {
|
|
1013
|
+
wait("waiting for the page's ring", Date.parse(row.created_at) + PERSON_RING_WAIT_MS);
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
const missing = row.payload?.requires?.find((server) => !view.connected(server));
|
|
1017
|
+
if (missing !== undefined) {
|
|
1018
|
+
wait(`waiting for mcp server ${missing}`);
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
const idle = view.idle && !laneTaken;
|
|
1022
|
+
if (row.wake !== "turn") {
|
|
1023
|
+
actions.push({ do: "append", row, wakeAgent: !idle });
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
if (!idle) {
|
|
1027
|
+
wait("pi is busy");
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
laneTaken = true;
|
|
1031
|
+
actions.push({ do: "turn", row });
|
|
1032
|
+
}
|
|
1033
|
+
return actions;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
export interface Executor {
|
|
1037
|
+
/** Read from disk each call: the conversation can be taken while a drain runs. A non-holder still drains the local rows it produced (`ExecutorView.mine`). */
|
|
1038
|
+
holds(sessionId: string): boolean;
|
|
1039
|
+
view(sessionId: string): ExecutorView;
|
|
1040
|
+
entries(sessionId: string): ConversationView;
|
|
1041
|
+
/** Hand a turn row to pi; `now` is when, for `ExecutorView.inFlightSince`. */
|
|
1042
|
+
turn(row: SessionRequest, now: number): void;
|
|
1043
|
+
append(row: SessionRequest, wakeAgent: boolean): void;
|
|
1044
|
+
/** Stop the turn running now. */
|
|
1045
|
+
abort(): void;
|
|
1046
|
+
/** Stop the turn of row `id`, handed to pi and not started yet, as it starts. */
|
|
1047
|
+
abortWhenStarted(id: string): void;
|
|
1048
|
+
/** pi refused row `id` (a `fail` action): it is no longer in flight. */
|
|
1049
|
+
refused(id: string): void;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** Where the engine records what it did: the jobs it checks rows against, the inbox for dead letters, the audit. */
|
|
1053
|
+
export interface EngineSinks {
|
|
1054
|
+
jobs: JobsView;
|
|
1055
|
+
inbox: Pick<Inbox, "append" | "appendOnce">;
|
|
1056
|
+
audit: Pick<TriggerStore, "appendAudit" | "listAudit">;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
export interface DrainResult {
|
|
1060
|
+
/** Handed to the executor this drain. */
|
|
1061
|
+
delivered: string[];
|
|
1062
|
+
/** Taken out of the file: done, cancelled, stale, or a removed job's. */
|
|
1063
|
+
removed: string[];
|
|
1064
|
+
failed: string[];
|
|
1065
|
+
dead: string[];
|
|
1066
|
+
waiting: string[];
|
|
1067
|
+
/** The earliest moment a wait above ends on its own (the ring wait): worth a drain then, not at the next tick. */
|
|
1068
|
+
retryAt?: number;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const stripPrefix = (text: string) => text.replace(/^\[Trigger [^\]]+\]\s*/, "");
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* The dead letter: the finding first, then the audit line, then the removal. A crash after the
|
|
1075
|
+
* finding writes it again on the next drain and loses nothing; the reverse order lost the row.
|
|
1076
|
+
*/
|
|
1077
|
+
/** A prompt that never reached its conversation, as its inbox finding reads: why first, then the prompt. */
|
|
1078
|
+
export function deadLetterText(why: string, prompt: string): string {
|
|
1079
|
+
return `could not be delivered (${why}): ${prompt}`;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/** The parts of a finding `deadLetterText` wrote, or undefined for any other finding. */
|
|
1083
|
+
export function deadLetterParts(text: string): { why: string; prompt: string } | undefined {
|
|
1084
|
+
const m = /^could not be delivered \((.*?)\): ([\s\S]*)$/.exec(text);
|
|
1085
|
+
return m ? { why: m[1], prompt: m[2] } : undefined;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
async function deadLetter(store: RequestStore, sinks: EngineSinks, sessionId: string, row: SessionRequest, why: string): Promise<void> {
|
|
1089
|
+
const text = stripPrefix(row.payload?.text ?? "");
|
|
1090
|
+
const cwd = row.payload?.cwd ?? "";
|
|
1091
|
+
// A rule's result keeps the finding it always had in the inbox (`trigger:<label>`): the
|
|
1092
|
+
// since-you-looked count and `/inbox open` tell results from runs by that source. Any other row
|
|
1093
|
+
// was meant to run in the conversation, so its finding says why it did not, before a claim reruns it.
|
|
1094
|
+
const rule = row.source.startsWith("rule:") ? row.source.slice("rule:".length) : undefined;
|
|
1095
|
+
// The check that produced a rule's result decided its finding already (src/trigger-runtime.ts:
|
|
1096
|
+
// filed it, or skipped a repeat), so its dead letter files nothing; a result no check decided is
|
|
1097
|
+
// filed once by its run (Inbox.appendOnce).
|
|
1098
|
+
if (rule !== undefined) {
|
|
1099
|
+
if (!checkDecidedFinding(sinks.audit, row.id)) await sinks.inbox.appendOnce({ source: `trigger:${rule}`, run_id: row.id, job_id: rule, cwd, text: text || `rule result ${row.id}`, session_id: sessionId }, { run_id: row.id, source: `trigger:${rule}` });
|
|
1100
|
+
}
|
|
1101
|
+
else await sinks.inbox.append({ source: row.source, run_id: row.id, cwd, text: deadLetterText(why, text.slice(0, DEAD_LETTER_CHARS) || `${row.kind} request ${row.id}`), session_id: sessionId });
|
|
1102
|
+
sinks.audit.appendAudit({ cwd, type: "request", traceId: row.id, state: "dead", sourceLabel: row.source, eventLabel: row.session_id, summary: text, details: { session_id: row.session_id, kind: row.kind, attempts: row.attempts, last_error: row.last_error, reason: why } });
|
|
1103
|
+
store.remove(sessionId, [row.id]);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Deliver what is queued for `sessionId` through `executor`: every row if it holds the conversation,
|
|
1108
|
+
* else only the local rows it produced itself. Read the rows, decide, then apply each action, asking
|
|
1109
|
+
* `holds()` again before each one.
|
|
1110
|
+
*/
|
|
1111
|
+
export function drain(sessionId: string, store: RequestStore, executor: Executor, now: number, sinks: EngineSinks, opts: DrainOptions = {}): Promise<DrainResult> {
|
|
1112
|
+
// One drain of a session at a time in this process: the tick, the bell and a promotion each start
|
|
1113
|
+
// one, and two that overlap would hand a row over, or write a dead letter, twice.
|
|
1114
|
+
const key = `${store.dir}\0${sessionId}`;
|
|
1115
|
+
// `now` is the caller's clock when it asked; the drain may wait behind another, and awaits dead
|
|
1116
|
+
// letters of its own. `asked` lets a stamp be written at the moment it is written, in the
|
|
1117
|
+
// caller's clock: a message the person typed in the meantime must not prove a row stamped before it.
|
|
1118
|
+
const asked = Date.now();
|
|
1119
|
+
const next = (draining.get(key) ?? Promise.resolve()).then(() => drainOnce(sessionId, store, executor, now, sinks, opts, asked));
|
|
1120
|
+
const settled = next.catch(() => undefined);
|
|
1121
|
+
draining.set(key, settled);
|
|
1122
|
+
settled
|
|
1123
|
+
.then(() => {
|
|
1124
|
+
if (draining.get(key) === settled) draining.delete(key);
|
|
1125
|
+
})
|
|
1126
|
+
.catch(() => undefined); // a map delete cannot reject
|
|
1127
|
+
return next;
|
|
1128
|
+
}
|
|
1129
|
+
const draining = new Map<string, Promise<unknown>>();
|
|
1130
|
+
|
|
1131
|
+
async function drainOnce(sessionId: string, store: RequestStore, executor: Executor, now: number, sinks: EngineSinks, opts: DrainOptions, asked = Date.now()): Promise<DrainResult> {
|
|
1132
|
+
/** The caller's clock, read now: `now` plus what has passed since the drain was asked for. */
|
|
1133
|
+
const moment = () => now + (Date.now() - asked);
|
|
1134
|
+
const out: DrainResult = { delivered: [], removed: [], failed: [], dead: [], waiting: [] };
|
|
1135
|
+
const view = executor.view(sessionId);
|
|
1136
|
+
// A window that does not hold the conversation (a viewer, one standing aside, a `pi -p` run)
|
|
1137
|
+
// still runs what the person typed into it and its own goal's continuations; and the browser
|
|
1138
|
+
// page's rows it was rung for, when the holder will not deliver them (`ExecutorView.holderDelivers`).
|
|
1139
|
+
const own = (row: SessionRequest) => view.mine(row) && (isLocal(row) || (row.source === "person" && view.holderDelivers === false));
|
|
1140
|
+
const may = (row?: SessionRequest) => executor.holds(sessionId) || (!!row && own(row));
|
|
1141
|
+
const rows = may() ? store.list(sessionId) : store.list(sessionId).filter(own);
|
|
1142
|
+
if (!rows.length) return out;
|
|
1143
|
+
const actions = decideDrain(rows, executor.entries(sessionId), sinks.jobs, view, now, opts);
|
|
1144
|
+
/**
|
|
1145
|
+
* One more failure of `row`: at `DEAD_AFTER_ATTEMPTS` a pi-loops row is a dead letter; a person row
|
|
1146
|
+
* never is (design §5), it keeps its error for the page. Otherwise the count, the error and, when
|
|
1147
|
+
* the retry backs off, when it may go again are written on the row, and the audit says `failed`.
|
|
1148
|
+
*/
|
|
1149
|
+
const failed = async (row: SessionRequest, why: string, retry: "never" | "backoff" | "now"): Promise<void> => {
|
|
1150
|
+
const attempts = row.attempts + 1;
|
|
1151
|
+
out.failed.push(row.id);
|
|
1152
|
+
if (row.source !== "person" && attempts >= DEAD_AFTER_ATTEMPTS) {
|
|
1153
|
+
await deadLetter(store, sinks, sessionId, { ...row, attempts, last_error: why }, `failed ${attempts} times: ${why}`);
|
|
1154
|
+
out.dead.push(row.id);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
store.update(sessionId, row.id, { attempts, last_error: why, ...(retry === "backoff" ? { not_before: stamp(now + backoffFor(attempts)) } : {}) });
|
|
1158
|
+
sinks.audit.appendAudit({ cwd: row.payload?.cwd ?? "", type: "request", traceId: row.id, state: "failed", sourceLabel: row.source, eventLabel: row.session_id, details: { session_id: row.session_id, attempts, error: why } });
|
|
1159
|
+
};
|
|
1160
|
+
for (const action of actions) {
|
|
1161
|
+
// Before each one, not once: the conversation can be taken while this drains.
|
|
1162
|
+
if (!may("row" in action ? action.row : undefined)) break;
|
|
1163
|
+
switch (action.do) {
|
|
1164
|
+
case "wait":
|
|
1165
|
+
out.waiting.push(action.row.id);
|
|
1166
|
+
if (action.until !== undefined && (out.retryAt === undefined || action.until < out.retryAt)) out.retryAt = action.until;
|
|
1167
|
+
break;
|
|
1168
|
+
case "remove":
|
|
1169
|
+
// A done input row's record is the session file; everything else ends in the audit.
|
|
1170
|
+
if (action.why !== "done" || action.row.kind !== "input") sinks.audit.appendAudit({ cwd: action.row.payload?.cwd ?? "", type: "request", traceId: action.row.id, state: action.why, sourceLabel: action.row.source, eventLabel: action.row.session_id, details: { session_id: action.row.session_id, kind: action.row.kind } });
|
|
1171
|
+
store.remove(sessionId, [action.row.id]);
|
|
1172
|
+
out.removed.push(action.row.id);
|
|
1173
|
+
break;
|
|
1174
|
+
case "dead":
|
|
1175
|
+
await deadLetter(store, sinks, sessionId, action.row, action.why);
|
|
1176
|
+
out.dead.push(action.row.id);
|
|
1177
|
+
break;
|
|
1178
|
+
case "abort":
|
|
1179
|
+
executor.abort();
|
|
1180
|
+
break;
|
|
1181
|
+
case "abortWhenStarted":
|
|
1182
|
+
executor.abortWhenStarted(action.target);
|
|
1183
|
+
break;
|
|
1184
|
+
case "fail":
|
|
1185
|
+
await failed(action.row, action.why, action.retry);
|
|
1186
|
+
executor.refused(action.row.id);
|
|
1187
|
+
break;
|
|
1188
|
+
case "append":
|
|
1189
|
+
case "turn": {
|
|
1190
|
+
const row = action.row;
|
|
1191
|
+
// Drains overlap (the tick, the bell, a promotion), and one can hand a row over while this
|
|
1192
|
+
// one awaits a dead letter: ask again at the moment of handing it over.
|
|
1193
|
+
if (executor.view(sessionId).inFlight.has(row.id) || landed(row, executor.entries(sessionId))) break;
|
|
1194
|
+
try {
|
|
1195
|
+
// The stamp first, under the lock: the proof of a text-proved row's delivery is a user message
|
|
1196
|
+
// written at or after it. A crash between the two leaves a stamped row with no message,
|
|
1197
|
+
// handed over again with a new stamp; the reverse order could lose the proof of a send.
|
|
1198
|
+
// A stamp that found no row hands nothing over: the row left the file since this drain
|
|
1199
|
+
// read it (cancelled, or taken back by the page and sent over rpc), and a turn now would
|
|
1200
|
+
// answer it a second time.
|
|
1201
|
+
if (action.do === "turn" && !carriesPrefix(row.source) && !store.update(sessionId, row.id, { handed_at: stamp(moment()) })) break;
|
|
1202
|
+
if (action.do === "append") executor.append(row, action.wakeAgent);
|
|
1203
|
+
else executor.turn(row, now);
|
|
1204
|
+
} catch (err: any) {
|
|
1205
|
+
await failed(row, redact(err?.message ?? String(err)), "backoff");
|
|
1206
|
+
break;
|
|
1207
|
+
}
|
|
1208
|
+
out.delivered.push(row.id);
|
|
1209
|
+
// Written into the conversation already (an idle window, the background process): gone now.
|
|
1210
|
+
// Otherwise pi holds it as a follow-up and the next drain finds it in the file, or not.
|
|
1211
|
+
if (landed(row, executor.entries(sessionId))) {
|
|
1212
|
+
store.remove(sessionId, [row.id]);
|
|
1213
|
+
out.removed.push(row.id);
|
|
1214
|
+
}
|
|
1215
|
+
break;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return out;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Every row of a session that can never take them (its file is gone): a pi-loops input becomes a
|
|
1224
|
+
* dead letter, in the same order as a failed row (the finding, the audit line, the removal). A stop
|
|
1225
|
+
* or a cancel has nothing to tell anyone, and a person's message never goes to the inbox (design
|
|
1226
|
+
* §5: the page is where it is answered or given back), so those are audited `stale` and removed,
|
|
1227
|
+
* with no finding.
|
|
1228
|
+
*/
|
|
1229
|
+
export async function bury(sessionId: string, store: RequestStore, sinks: EngineSinks, why: string): Promise<string[]> {
|
|
1230
|
+
const ids: string[] = [];
|
|
1231
|
+
for (const row of store.list(sessionId)) {
|
|
1232
|
+
if (row.kind === "input" && row.source !== "person") await deadLetter(store, sinks, sessionId, row, why);
|
|
1233
|
+
else {
|
|
1234
|
+
sinks.audit.appendAudit({ cwd: "", type: "request", traceId: row.id, state: "stale", sourceLabel: row.source, eventLabel: row.session_id, details: { session_id: row.session_id, kind: row.kind, reason: why } });
|
|
1235
|
+
store.remove(sessionId, [row.id]);
|
|
1236
|
+
}
|
|
1237
|
+
ids.push(row.id);
|
|
1238
|
+
}
|
|
1239
|
+
return ids;
|
|
1240
|
+
}
|