@timqi/pier 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -12
- package/dist/agent/config.js +273 -27
- package/dist/agent/credentials.js +18 -12
- package/dist/agent/events.js +5 -41
- package/dist/agent/models.js +12 -0
- package/dist/agent/pi.js +182 -27
- package/dist/boards/boards.js +20 -10
- package/dist/channels/routes.js +1 -1
- package/dist/channels/runtime.js +36 -5
- package/dist/channels/slack-api.js +2 -4
- package/dist/channels/slack-outbound.js +4 -8
- package/dist/channels/slack-render.js +1 -4
- package/dist/channels/slack.js +20 -9
- package/dist/channels/telegram-api.js +3 -4
- package/dist/channels/telegram.js +37 -28
- package/dist/cli.js +177 -29
- package/dist/core/hub.js +36 -5
- package/dist/core/identity.js +5 -0
- package/dist/core/inbound-file.js +70 -0
- package/dist/core/inbox.js +32 -0
- package/dist/core/queue.js +9 -3
- package/dist/core/reply.js +20 -5
- package/dist/core/router.js +186 -14
- package/dist/core/types.js +53 -0
- package/dist/db.js +54 -8
- package/dist/drain.js +145 -0
- package/dist/main.js +86 -18
- package/dist/secrets.js +10 -6
- package/dist/service.js +142 -18
- package/dist/settings.js +69 -8
- package/dist/tasks/agent.js +41 -5
- package/dist/tasks/callbacks.js +29 -89
- package/dist/tasks/definitions.js +2 -6
- package/dist/tasks/execution.js +10 -1
- package/dist/tasks/groups.js +20 -49
- package/dist/tasks/messages.js +106 -21
- package/dist/tasks/outbox.js +157 -0
- package/dist/tasks/routes.js +6 -4
- package/dist/tasks/service.js +79 -22
- package/dist/tasks/store.js +48 -55
- package/dist/tasks/tool.js +19 -4
- package/dist/tasks/types.js +7 -0
- package/dist/update.js +94 -0
- package/dist/web/auth.js +75 -22
- package/dist/web/explorer.js +146 -0
- package/dist/web/files.js +26 -11
- package/dist/web/instance.js +99 -0
- package/dist/web/provider-flows.js +249 -0
- package/dist/web/providers.js +129 -0
- package/dist/web/public/assets/index-BK64pHmP.js +90 -0
- package/dist/web/public/assets/index-De4GlOq4.css +2 -0
- package/dist/web/public/icon-192.png +0 -0
- package/dist/web/public/icon-32.png +0 -0
- package/dist/web/public/icon-512.png +0 -0
- package/dist/web/public/icon-maskable-512.png +0 -0
- package/dist/web/public/icon-touch-192.png +0 -0
- package/dist/web/public/icon.svg +29 -11
- package/dist/web/public/index.html +43 -28
- package/dist/web/server.js +47 -120
- package/docs/deploy.md +120 -64
- package/package.json +1 -1
- package/skills/pier-help/SKILL.md +110 -0
- package/skills/pier-slack/SKILL.md +3 -2
- package/skills/pier-tasks/SKILL.md +19 -12
- package/dist/web/public/assets/index-8CinH1uR.css +0 -2
- package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
- package/dist/web/public/sw.js +0 -21
package/dist/tasks/callbacks.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
// What a finished run says to the session that delegated it. Delivery itself
|
|
2
|
+
// belongs to outbox.ts; this file owns the run vocabulary and the batching.
|
|
1
3
|
import { Router } from "../core/router.js";
|
|
2
|
-
import {
|
|
4
|
+
import { Outbox } from "./outbox.js";
|
|
3
5
|
import { TaskStore } from "./store.js";
|
|
4
|
-
const log = logger("tasks");
|
|
5
6
|
export function runResultText(run) {
|
|
6
7
|
let result = run.error ?? "No result";
|
|
7
8
|
if (run.result?.type === "agent")
|
|
@@ -13,18 +14,31 @@ export function runResultText(run) {
|
|
|
13
14
|
if (run.result?.type === "watch")
|
|
14
15
|
result = "Watch condition did not match";
|
|
15
16
|
if (result.length > 8000)
|
|
16
|
-
result = `${result.slice(0, 8000)}\n[truncated
|
|
17
|
+
result = `${result.slice(0, 8000)}\n[truncated — task tool get run_id ${run.id} returns the full text]`;
|
|
17
18
|
return result;
|
|
18
19
|
}
|
|
19
20
|
export class TaskCallbacks {
|
|
20
21
|
store;
|
|
21
|
-
|
|
22
|
-
changed
|
|
23
|
-
delivering = new Set();
|
|
24
|
-
constructor(store, router, changed) {
|
|
22
|
+
outbox;
|
|
23
|
+
constructor(store, router, changed, unreachable) {
|
|
25
24
|
this.store = store;
|
|
26
|
-
this.
|
|
27
|
-
|
|
25
|
+
this.outbox = new Outbox(router, {
|
|
26
|
+
id: (run) => run.id,
|
|
27
|
+
reload: (id) => this.store.getRun(id),
|
|
28
|
+
save: (run) => { this.store.saveRun(run); },
|
|
29
|
+
changed,
|
|
30
|
+
input: (runs) => ({
|
|
31
|
+
text: this.text(runs),
|
|
32
|
+
origin: {
|
|
33
|
+
kind: "task-callback",
|
|
34
|
+
taskId: runs[0].taskId,
|
|
35
|
+
runId: runs[0].id,
|
|
36
|
+
sourceSessionId: runs[0].targetSessionId,
|
|
37
|
+
runIds: runs.map((run) => run.id),
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
describe: (run) => `the result of "${run.context.definition.name}"`,
|
|
41
|
+
}, unreachable);
|
|
28
42
|
}
|
|
29
43
|
target(callback, origin) {
|
|
30
44
|
if (callback.type === "session")
|
|
@@ -37,94 +51,20 @@ export class TaskCallbacks {
|
|
|
37
51
|
for (const run of this.store.listPendingCallbacks(now))
|
|
38
52
|
void this.deliver(run);
|
|
39
53
|
}
|
|
40
|
-
/** Delivers the candidate
|
|
41
|
-
*
|
|
42
|
-
* backlog instead of one turn per run. */
|
|
54
|
+
/** Delivers the candidate together with every other deliverable callback
|
|
55
|
+
* aimed at the same session. */
|
|
43
56
|
async deliver(candidate) {
|
|
44
|
-
if (this.delivering.has(candidate.id))
|
|
45
|
-
return;
|
|
46
57
|
const first = this.store.getRun(candidate.id);
|
|
47
58
|
if (!first?.callbackSessionId || (first.callbackState !== "pending" && first.callbackState !== "failed"))
|
|
48
59
|
return;
|
|
49
60
|
const sessionId = first.callbackSessionId;
|
|
50
|
-
//
|
|
61
|
+
// Retry due-times are ignored when sweeping the batch: once one callback is
|
|
51
62
|
// deliverable, everything pending for the session rides along.
|
|
52
|
-
const batch = this.store.listPendingCallbacks(Number.MAX_SAFE_INTEGER)
|
|
63
|
+
const batch = this.store.listPendingCallbacks(Number.MAX_SAFE_INTEGER)
|
|
64
|
+
.filter((run) => run.callbackSessionId === sessionId);
|
|
53
65
|
if (!batch.some((run) => run.id === first.id))
|
|
54
66
|
return;
|
|
55
|
-
|
|
56
|
-
this.delivering.add(run.id);
|
|
57
|
-
try {
|
|
58
|
-
const session = await this.router.ensure({ channelId: "task", conversationId: sessionId });
|
|
59
|
-
// Crash-window idempotency: any run id already present in a persisted
|
|
60
|
-
// callback input (single or batched) must not be sent again.
|
|
61
|
-
const seen = new Set();
|
|
62
|
-
for (const turn of await session.history()) {
|
|
63
|
-
if (turn.role !== "system" || turn.origin?.kind !== "task-callback")
|
|
64
|
-
continue;
|
|
65
|
-
for (const id of turn.origin.runIds ?? [turn.origin.runId])
|
|
66
|
-
seen.add(id);
|
|
67
|
-
}
|
|
68
|
-
const fresh = batch.filter((run) => !seen.has(run.id));
|
|
69
|
-
// Waiting for a busy target is not a delivery attempt: counting it would
|
|
70
|
-
// inflate `callbackAttempts` once per second and skip the real failure
|
|
71
|
-
// backoff straight to its ceiling.
|
|
72
|
-
if (fresh.length > 0 && session.state === "streaming") {
|
|
73
|
-
for (const run of batch) {
|
|
74
|
-
run.callbackNextAttemptAt = Date.now() + 1000;
|
|
75
|
-
this.store.saveRun(run);
|
|
76
|
-
}
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
for (const run of batch) {
|
|
80
|
-
run.callbackAttempts += 1;
|
|
81
|
-
run.callbackState = "pending";
|
|
82
|
-
run.callbackError = null;
|
|
83
|
-
this.store.saveRun(run);
|
|
84
|
-
}
|
|
85
|
-
// `systemInput` resolves when the turn it triggers settles, not when Pi
|
|
86
|
-
// accepts the input — so mark delivered first and let a rejection below
|
|
87
|
-
// flip it to failed. Otherwise a recipient turn that runs for minutes
|
|
88
|
-
// leaves the run "pending" and a restart in that window re-delivers.
|
|
89
|
-
const sent = fresh.length > 0
|
|
90
|
-
? session.systemInput(this.text(fresh), {
|
|
91
|
-
kind: "task-callback",
|
|
92
|
-
taskId: fresh[0].taskId,
|
|
93
|
-
runId: fresh[0].id,
|
|
94
|
-
sourceSessionId: fresh[0].targetSessionId,
|
|
95
|
-
runIds: fresh.map((run) => run.id),
|
|
96
|
-
}, "followUp")
|
|
97
|
-
: Promise.resolve();
|
|
98
|
-
for (const run of batch) {
|
|
99
|
-
run.callbackState = "delivered";
|
|
100
|
-
run.callbackNextAttemptAt = null;
|
|
101
|
-
this.store.saveRun(run);
|
|
102
|
-
this.changed(run);
|
|
103
|
-
}
|
|
104
|
-
if (fresh.length > 0) {
|
|
105
|
-
log.debug(`callback for ${fresh.map((run) => run.id).join(", ")} → session ${sessionId}`);
|
|
106
|
-
}
|
|
107
|
-
await sent;
|
|
108
|
-
}
|
|
109
|
-
catch (error) {
|
|
110
|
-
// The delegating agent is waiting for an answer that is now late: the
|
|
111
|
-
// retry is silent, so this line is the only sign it is being retried.
|
|
112
|
-
log.warn(`callback to session ${sessionId} failed, will retry`, error);
|
|
113
|
-
for (const stale of batch) {
|
|
114
|
-
const run = this.store.getRun(stale.id);
|
|
115
|
-
if (!run)
|
|
116
|
-
continue;
|
|
117
|
-
run.callbackState = "failed";
|
|
118
|
-
run.callbackError = String(error);
|
|
119
|
-
run.callbackNextAttemptAt = Date.now() + Math.min(60_000, 1000 * 2 ** Math.min(run.callbackAttempts, 6));
|
|
120
|
-
this.store.saveRun(run);
|
|
121
|
-
this.changed(run);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
finally {
|
|
125
|
-
for (const run of batch)
|
|
126
|
-
this.delivering.delete(run.id);
|
|
127
|
-
}
|
|
67
|
+
await this.outbox.deliver(sessionId, batch);
|
|
128
68
|
}
|
|
129
69
|
text(runs) {
|
|
130
70
|
const sections = runs.map((run) => [
|
|
@@ -45,6 +45,8 @@ function parseTrigger(raw) {
|
|
|
45
45
|
}
|
|
46
46
|
throw new Error("unknown trigger type");
|
|
47
47
|
}
|
|
48
|
+
/** Always computed from `from` (boot recomputes from *now*): cron runs missed
|
|
49
|
+
* while Pier was down are skipped, never caught up — no double fire. */
|
|
48
50
|
export function nextRunAt(trigger, from) {
|
|
49
51
|
if (trigger.type === "manual")
|
|
50
52
|
return null;
|
|
@@ -266,12 +268,6 @@ export class TaskDefinitions {
|
|
|
266
268
|
await this.assertDirectory(cwd);
|
|
267
269
|
session = { mode: "fork", ...(cwd ? { cwd } : {}) };
|
|
268
270
|
}
|
|
269
|
-
else if (typeof raw.sessionId === "string" && raw.sessionId.trim()) {
|
|
270
|
-
const sessionId = raw.sessionId.trim();
|
|
271
|
-
if (!(await this.sessionExists(sessionId)))
|
|
272
|
-
throw new Error(`unknown session: ${sessionId}`);
|
|
273
|
-
session = { mode: "reuse", sessionId };
|
|
274
|
-
}
|
|
275
271
|
else {
|
|
276
272
|
// Validation never mutates: a dedicated session is created explicitly
|
|
277
273
|
// (POST /api/sessions) and then referenced with mode:"reuse".
|
package/dist/tasks/execution.js
CHANGED
|
@@ -86,8 +86,17 @@ export class TaskExecution {
|
|
|
86
86
|
// completion callback: the pending question is the notification.
|
|
87
87
|
if (run.callbackSessionId && !this.host.openDecisionId(run.id))
|
|
88
88
|
run.callbackState = "pending";
|
|
89
|
-
this.store.saveRun(run);
|
|
90
89
|
this.controllers.delete(run.id);
|
|
90
|
+
try {
|
|
91
|
+
this.store.saveRun(run);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
// Waiters settle from the in-memory run below; the callback and any
|
|
95
|
+
// group join read the stale row and wait — the next boot's interrupt
|
|
96
|
+
// sweep re-marks it and delivers then. Named here so that delay has
|
|
97
|
+
// an explanation.
|
|
98
|
+
log.error(`run ${run.id} final save failed — callback/join deferred to next boot`, err);
|
|
99
|
+
}
|
|
91
100
|
this.host.changed(run);
|
|
92
101
|
this.host.settled(run);
|
|
93
102
|
if (run.callbackState === "pending")
|
package/dist/tasks/groups.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { Router } from "../core/router.js";
|
|
3
3
|
import { logger } from "../log.js";
|
|
4
4
|
import { runResultText } from "./callbacks.js";
|
|
5
|
+
import { Outbox } from "./outbox.js";
|
|
5
6
|
import { TaskStore } from "./store.js";
|
|
6
7
|
import { isTerminal } from "./types.js";
|
|
7
8
|
const log = logger("tasks");
|
|
@@ -9,15 +10,26 @@ const log = logger("tasks");
|
|
|
9
10
|
* aggregated callback when the join condition is met (design 04). */
|
|
10
11
|
export class TaskGroups {
|
|
11
12
|
store;
|
|
12
|
-
router;
|
|
13
13
|
host;
|
|
14
14
|
changed;
|
|
15
|
-
|
|
16
|
-
constructor(store, router, host, changed) {
|
|
15
|
+
outbox;
|
|
16
|
+
constructor(store, router, host, changed, unreachable) {
|
|
17
17
|
this.store = store;
|
|
18
|
-
this.router = router;
|
|
19
18
|
this.host = host;
|
|
20
19
|
this.changed = changed;
|
|
20
|
+
this.outbox = new Outbox(router, {
|
|
21
|
+
id: (group) => group.id,
|
|
22
|
+
reload: (id) => this.store.getGroup(id),
|
|
23
|
+
save: (group) => { this.store.saveGroup(group); },
|
|
24
|
+
changed,
|
|
25
|
+
// A group names itself in the origin where a run names its run id, so the
|
|
26
|
+
// engine's transcript proof works unchanged.
|
|
27
|
+
input: (groups) => ({
|
|
28
|
+
text: this.text(groups[0]),
|
|
29
|
+
origin: { kind: "task-callback", taskId: groups[0].id, runId: groups[0].id, sourceSessionId: null },
|
|
30
|
+
}),
|
|
31
|
+
describe: (group) => `the result of a ${String(group.memberRunIds.length)}-run group`,
|
|
32
|
+
}, unreachable);
|
|
21
33
|
}
|
|
22
34
|
/** Enqueues every member or none: a partially started group is worse than
|
|
23
35
|
* a rejected one. */
|
|
@@ -113,53 +125,12 @@ export class TaskGroups {
|
|
|
113
125
|
if (group.callbackState === "pending")
|
|
114
126
|
void this.deliver(group);
|
|
115
127
|
}
|
|
116
|
-
/**
|
|
117
|
-
* the group id, backoff retry, restart recovery. */
|
|
128
|
+
/** The engine owns retry, proof and the ceiling; a group is a batch of one. */
|
|
118
129
|
async deliver(candidate) {
|
|
119
|
-
|
|
130
|
+
const group = this.store.getGroup(candidate.id);
|
|
131
|
+
if (!group?.callbackSessionId || (group.callbackState !== "pending" && group.callbackState !== "failed"))
|
|
120
132
|
return;
|
|
121
|
-
this.
|
|
122
|
-
try {
|
|
123
|
-
const group = this.store.getGroup(candidate.id);
|
|
124
|
-
if (!group?.callbackSessionId || (group.callbackState !== "pending" && group.callbackState !== "failed"))
|
|
125
|
-
return;
|
|
126
|
-
const session = await this.router.ensure({ channelId: "task", conversationId: group.callbackSessionId });
|
|
127
|
-
const alreadyDelivered = (await session.history()).some((turn) => turn.role === "system" && turn.origin?.kind === "task-callback" && turn.origin.runId === group.id);
|
|
128
|
-
// Busy target: waiting is not an attempt (see TaskCallbacks.deliver).
|
|
129
|
-
if (!alreadyDelivered && session.state === "streaming") {
|
|
130
|
-
group.callbackNextAttemptAt = Date.now() + 1000;
|
|
131
|
-
this.store.saveGroup(group);
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
group.callbackAttempts += 1;
|
|
135
|
-
group.callbackState = "pending";
|
|
136
|
-
group.callbackError = null;
|
|
137
|
-
this.store.saveGroup(group);
|
|
138
|
-
// Delivered means Pi accepted the input, not that the recipient's turn
|
|
139
|
-
// ended (see TaskCallbacks.deliver); a rejection flips it to failed.
|
|
140
|
-
const sent = alreadyDelivered
|
|
141
|
-
? Promise.resolve()
|
|
142
|
-
: session.systemInput(this.text(group), { kind: "task-callback", taskId: group.id, runId: group.id, sourceSessionId: null }, "followUp");
|
|
143
|
-
group.callbackState = "delivered";
|
|
144
|
-
group.callbackNextAttemptAt = null;
|
|
145
|
-
this.store.saveGroup(group);
|
|
146
|
-
this.changed(group);
|
|
147
|
-
await sent;
|
|
148
|
-
}
|
|
149
|
-
catch (error) {
|
|
150
|
-
log.warn(`group ${candidate.id} callback failed, will retry`, error);
|
|
151
|
-
const group = this.store.getGroup(candidate.id);
|
|
152
|
-
if (!group)
|
|
153
|
-
return;
|
|
154
|
-
group.callbackState = "failed";
|
|
155
|
-
group.callbackError = String(error);
|
|
156
|
-
group.callbackNextAttemptAt = Date.now() + Math.min(60_000, 1000 * 2 ** Math.min(group.callbackAttempts, 6));
|
|
157
|
-
this.store.saveGroup(group);
|
|
158
|
-
this.changed(group);
|
|
159
|
-
}
|
|
160
|
-
finally {
|
|
161
|
-
this.delivering.delete(candidate.id);
|
|
162
|
-
}
|
|
133
|
+
await this.outbox.deliver(group.callbackSessionId, [group]);
|
|
163
134
|
}
|
|
164
135
|
text(group) {
|
|
165
136
|
const members = group.memberRunIds.map((id) => this.host.getRun(id));
|
package/dist/tasks/messages.js
CHANGED
|
@@ -3,7 +3,7 @@ import { EventHub } from "../core/hub.js";
|
|
|
3
3
|
import { Router } from "../core/router.js";
|
|
4
4
|
import { logger } from "../log.js";
|
|
5
5
|
import { TaskStore } from "./store.js";
|
|
6
|
-
import { isTerminal } from "./types.js";
|
|
6
|
+
import { isTerminal, MAX_DELIVERY_ATTEMPTS, retryDelay, undeliverable } from "./types.js";
|
|
7
7
|
const log = logger("tasks");
|
|
8
8
|
const MAX_MESSAGE_LENGTH = 16 * 1024;
|
|
9
9
|
function bounded(content) {
|
|
@@ -19,16 +19,17 @@ export class TaskMessenger {
|
|
|
19
19
|
router;
|
|
20
20
|
hub;
|
|
21
21
|
resumeRun;
|
|
22
|
-
|
|
23
|
-
* expires undelivered control messages and re-offers decisions anyway. */
|
|
24
|
-
retries = new Map();
|
|
22
|
+
unreachable;
|
|
25
23
|
constructor(store, router, hub,
|
|
26
24
|
/** Continues a terminal child with a supervisor reply as its prompt. */
|
|
27
|
-
resumeRun
|
|
25
|
+
resumeRun,
|
|
26
|
+
/** Reports a delivery nobody can complete (service.ts owns the surfaces). */
|
|
27
|
+
unreachable) {
|
|
28
28
|
this.store = store;
|
|
29
29
|
this.router = router;
|
|
30
30
|
this.hub = hub;
|
|
31
31
|
this.resumeRun = resumeRun;
|
|
32
|
+
this.unreachable = unreachable;
|
|
32
33
|
}
|
|
33
34
|
expirePending() {
|
|
34
35
|
for (const message of this.store.expirePendingMessages())
|
|
@@ -54,8 +55,8 @@ export class TaskMessenger {
|
|
|
54
55
|
list(runId) {
|
|
55
56
|
return this.store.listMessages(runId);
|
|
56
57
|
}
|
|
57
|
-
recent(since
|
|
58
|
-
return this.store.listRecentMessages(since
|
|
58
|
+
recent(since) {
|
|
59
|
+
return this.store.listRecentMessages(since);
|
|
59
60
|
}
|
|
60
61
|
async control(run, fromSessionId, kind, content) {
|
|
61
62
|
const message = this.create(run, kind, fromSessionId, run.targetSessionId ?? "", content, null);
|
|
@@ -88,7 +89,7 @@ export class TaskMessenger {
|
|
|
88
89
|
this.changed(message);
|
|
89
90
|
continue;
|
|
90
91
|
}
|
|
91
|
-
if ((
|
|
92
|
+
if ((message.nextAttemptAt ?? 0) > now)
|
|
92
93
|
continue;
|
|
93
94
|
const target = message.toSessionId || run.targetSessionId;
|
|
94
95
|
if (target)
|
|
@@ -163,6 +164,8 @@ export class TaskMessenger {
|
|
|
163
164
|
deliveredAt: null,
|
|
164
165
|
answeredAt: null,
|
|
165
166
|
error: null,
|
|
167
|
+
attempts: 0,
|
|
168
|
+
nextAttemptAt: null,
|
|
166
169
|
};
|
|
167
170
|
this.store.saveMessage(message);
|
|
168
171
|
this.changed(message);
|
|
@@ -171,47 +174,129 @@ export class TaskMessenger {
|
|
|
171
174
|
/** Never awaits the recipient: the seam's `systemInput` settles with the turn
|
|
172
175
|
* the input triggers, so awaiting it would block the sender — a child's
|
|
173
176
|
* `contact` on its supervisor's whole answer turn — which the design forbids.
|
|
174
|
-
*
|
|
175
|
-
*
|
|
177
|
+
* So `delivered` is written by `confirmed`, against the one proof that
|
|
178
|
+
* survives an abort or a restart: the message visible in the recipient's own
|
|
179
|
+
* transcript. Until then it stays pending and the sweep tries again. */
|
|
176
180
|
deliver(candidate, run, targetSessionId) {
|
|
177
181
|
const message = this.require(candidate.id);
|
|
178
182
|
if (message.state !== "pending" && message.state !== "failed")
|
|
179
183
|
return;
|
|
180
184
|
if (message.toSessionId !== targetSessionId)
|
|
181
185
|
message.toSessionId = targetSessionId;
|
|
186
|
+
message.state = "pending";
|
|
187
|
+
message.error = null;
|
|
188
|
+
this.store.saveMessage(message);
|
|
189
|
+
this.changed(message);
|
|
190
|
+
void this.inject(message, run, targetSessionId, this.mode(message))
|
|
191
|
+
.catch((error) => log.error(`message ${message.id} delivery collapsed`, error));
|
|
192
|
+
}
|
|
193
|
+
/** The one place a message becomes delivered. */
|
|
194
|
+
confirmed(id) {
|
|
195
|
+
const message = this.store.getMessage(id);
|
|
196
|
+
if (!message || message.state !== "pending")
|
|
197
|
+
return;
|
|
182
198
|
message.state = "delivered";
|
|
183
199
|
message.deliveredAt = Date.now();
|
|
184
200
|
message.error = null;
|
|
201
|
+
message.nextAttemptAt = null;
|
|
185
202
|
this.store.saveMessage(message);
|
|
186
203
|
this.changed(message);
|
|
187
|
-
|
|
188
|
-
|
|
204
|
+
}
|
|
205
|
+
/** Counts one hand-off and says whether it may happen: a recipient that
|
|
206
|
+
* never records the message must not be re-sent once a second forever.
|
|
207
|
+
* False means the ceiling was reached and the message is now expired. */
|
|
208
|
+
spend(id) {
|
|
209
|
+
const message = this.store.getMessage(id);
|
|
210
|
+
if (!message || message.state !== "pending")
|
|
211
|
+
return false;
|
|
212
|
+
message.attempts += 1;
|
|
213
|
+
message.nextAttemptAt = Date.now() + retryDelay(message.attempts);
|
|
214
|
+
if (message.attempts > MAX_DELIVERY_ATTEMPTS) {
|
|
215
|
+
this.abandon(message, undeliverable(message.attempts - 1, message.error));
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
this.store.saveMessage(message);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
/** Out of attempts. Both ends are told — the recipient that was owed it and
|
|
222
|
+
* the sender waiting on the answer — and a decision that expires here stops
|
|
223
|
+
* suppressing its run's completion callback, which `execution.ts` decided
|
|
224
|
+
* once, at the end of the run, and never revisits. */
|
|
225
|
+
abandon(message, why) {
|
|
226
|
+
message.state = "expired";
|
|
227
|
+
message.error = why;
|
|
228
|
+
message.answeredAt = Date.now();
|
|
229
|
+
message.nextAttemptAt = null;
|
|
230
|
+
this.store.saveMessage(message);
|
|
231
|
+
this.changed(message);
|
|
232
|
+
this.unreachable(message.toSessionId, `a ${message.kind} from run ${message.runId}`, why);
|
|
233
|
+
if (message.fromSessionId && message.fromSessionId !== message.toSessionId) {
|
|
234
|
+
this.unreachable(message.fromSessionId, `your ${message.kind} on run ${message.runId}`, why);
|
|
235
|
+
}
|
|
236
|
+
const run = this.store.getRun(message.runId);
|
|
237
|
+
if (message.kind !== "decision" || !run?.callbackSessionId)
|
|
238
|
+
return;
|
|
239
|
+
if (run.callbackState === null && isTerminal(run.state)) {
|
|
240
|
+
run.callbackState = "pending"; // the tick sweep delivers it
|
|
241
|
+
this.store.saveRun(run);
|
|
242
|
+
}
|
|
189
243
|
}
|
|
190
244
|
/** A decision steers — a follow-up would land only after the supervisor runs
|
|
191
245
|
* out of tool calls, leaving the child waiting out the whole turn. */
|
|
192
246
|
mode(message) {
|
|
193
247
|
return message.kind === "steer" || message.kind === "decision" ? "steer" : "follow_up";
|
|
194
248
|
}
|
|
195
|
-
failed(id, error) {
|
|
249
|
+
failed(id, error, spent) {
|
|
196
250
|
const message = this.store.getMessage(id);
|
|
197
|
-
|
|
251
|
+
// Only a still-pending message can fail: a late rejection must not undo a
|
|
252
|
+
// delivery a newer attempt already proved, nor revive an expired one.
|
|
253
|
+
if (message?.state !== "pending")
|
|
198
254
|
return;
|
|
199
255
|
// Both ends are waiting on this one: the sender for an answer, the
|
|
200
256
|
// recipient for a message it never got told about.
|
|
201
257
|
log.warn(`${message.kind} ${id} to session ${message.toSessionId} failed`, error);
|
|
202
258
|
message.state = "failed";
|
|
203
259
|
message.error = String(error);
|
|
260
|
+
// A pass that died before the send never spent its attempt — an
|
|
261
|
+
// unresolvable target dies there every time, and would retry forever.
|
|
262
|
+
if (!spent)
|
|
263
|
+
message.attempts += 1;
|
|
264
|
+
message.nextAttemptAt = Date.now() + retryDelay(message.attempts);
|
|
204
265
|
this.store.saveMessage(message);
|
|
205
266
|
this.changed(message);
|
|
206
|
-
|
|
207
|
-
|
|
267
|
+
if (message.attempts > MAX_DELIVERY_ATTEMPTS) {
|
|
268
|
+
this.abandon(message, undeliverable(message.attempts - 1, message.error));
|
|
269
|
+
}
|
|
208
270
|
}
|
|
271
|
+
/** One pass at getting the message into the recipient: the dedupe on a retry
|
|
272
|
+
* and the proof of delivery are the same transcript read. Owns its own
|
|
273
|
+
* failure, so an attempt is counted exactly once whether the pass died
|
|
274
|
+
* before the send or the send itself was refused. */
|
|
209
275
|
async inject(message, run, targetSessionId, mode) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
276
|
+
let spent = false;
|
|
277
|
+
try {
|
|
278
|
+
const session = await this.router.ensure({ channelId: "task", conversationId: targetSessionId });
|
|
279
|
+
const recorded = async () => (await session.history()).some((turn) => turn.role === "system" && turn.origin?.kind === "task-message" && turn.origin.messageId === message.id);
|
|
280
|
+
if (await recorded())
|
|
281
|
+
return this.confirmed(message.id);
|
|
282
|
+
// Accepted and waiting in Pi's queue for the running turn to drain it:
|
|
283
|
+
// not recorded yet, and sending again would deliver the same steer twice.
|
|
284
|
+
// Unlike a callback this is not deferred — a steer's whole point is to
|
|
285
|
+
// reach the turn already running — so the queue is where it sits, and
|
|
286
|
+
// waiting for it to drain costs no attempt.
|
|
287
|
+
const queue = await session.pendingQueue();
|
|
288
|
+
if ([...queue.steering, ...queue.followUp].some((text) => text.includes(message.id)))
|
|
289
|
+
return;
|
|
290
|
+
spent = this.spend(message.id);
|
|
291
|
+
if (!spent)
|
|
292
|
+
return;
|
|
293
|
+
await session.systemInput(this.format(message, run), this.origin(message, run), mode === "follow_up" ? "followUp" : mode);
|
|
294
|
+
if (await recorded())
|
|
295
|
+
this.confirmed(message.id);
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
this.failed(message.id, error, spent);
|
|
299
|
+
}
|
|
215
300
|
}
|
|
216
301
|
format(message, run) {
|
|
217
302
|
const title = run.context.definition.name;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// One delivery engine for everything that has to reach a session as a system
|
|
2
|
+
// input: run callbacks and group callbacks. Its whole reason to exist is that
|
|
3
|
+
// this logic was written twice and the two copies drifted — the ceiling was
|
|
4
|
+
// checked in one order here and another there, and only one of them counted a
|
|
5
|
+
// failed attempt.
|
|
6
|
+
import { Router } from "../core/router.js";
|
|
7
|
+
import { logger } from "../log.js";
|
|
8
|
+
import { MAX_DELIVERY_ATTEMPTS, retryDelay, undeliverable } from "./types.js";
|
|
9
|
+
const log = logger("tasks");
|
|
10
|
+
export class Outbox {
|
|
11
|
+
router;
|
|
12
|
+
kind;
|
|
13
|
+
unreachable;
|
|
14
|
+
delivering = new Set();
|
|
15
|
+
constructor(router, kind,
|
|
16
|
+
/** Reports a delivery nobody can complete (service.ts owns the surfaces). */
|
|
17
|
+
unreachable) {
|
|
18
|
+
this.router = router;
|
|
19
|
+
this.kind = kind;
|
|
20
|
+
this.unreachable = unreachable;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Delivers a batch aimed at one session: one model turn drains the backlog
|
|
24
|
+
* instead of one turn per record.
|
|
25
|
+
*
|
|
26
|
+
* `delivered` is written only against proof — the input visible in the
|
|
27
|
+
* recipient's own transcript. Pi's queues are memory, so an abort or a
|
|
28
|
+
* restart drops an accepted input and `systemInput` resolving proves
|
|
29
|
+
* nothing; a delivery reported on a resolved send is how a delegating agent
|
|
30
|
+
* ends up waiting forever on a result that was never read.
|
|
31
|
+
*/
|
|
32
|
+
async deliver(sessionId, batch) {
|
|
33
|
+
const mine = batch.filter((record) => !this.delivering.has(this.kind.id(record)));
|
|
34
|
+
if (mine.length === 0)
|
|
35
|
+
return;
|
|
36
|
+
for (const record of mine)
|
|
37
|
+
this.delivering.add(this.kind.id(record));
|
|
38
|
+
try {
|
|
39
|
+
const session = await this.router.ensure({ channelId: "task", conversationId: sessionId });
|
|
40
|
+
// The transcript read is both the crash-window dedupe and the proof.
|
|
41
|
+
const unproven = await this.settle(mine, session);
|
|
42
|
+
// Checked after the proof: a record whose input did land must not be
|
|
43
|
+
// given up on for having spent its last attempt landing it.
|
|
44
|
+
const live = unproven.filter((record) => !this.spent(record, sessionId));
|
|
45
|
+
if (live.length === 0)
|
|
46
|
+
return;
|
|
47
|
+
// Waiting for a busy target is not a delivery attempt: counting it would
|
|
48
|
+
// inflate the attempts once per second and skip the failure backoff
|
|
49
|
+
// straight to its ceiling.
|
|
50
|
+
if (session.state === "streaming") {
|
|
51
|
+
for (const record of live)
|
|
52
|
+
this.defer(record);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
for (const record of live)
|
|
56
|
+
this.sent(record);
|
|
57
|
+
const { text, origin } = this.kind.input(live);
|
|
58
|
+
log.debug(`callback for ${live.map((r) => this.kind.id(r)).join(", ")} → session ${sessionId}`);
|
|
59
|
+
// Not awaited: `systemInput` settles with the recipient's whole turn, and
|
|
60
|
+
// holding the delivery lock that long would keep the proof from ever
|
|
61
|
+
// being read — which is the only thing that marks this delivered.
|
|
62
|
+
session.systemInput(text, origin, "followUp")
|
|
63
|
+
.catch((error) => this.retry(sessionId, live, error));
|
|
64
|
+
// Pi records the input as it starts the turn, so the proof is usually
|
|
65
|
+
// here already; the tick sweep is the backstop when it is not.
|
|
66
|
+
await this.settle(live, session);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
this.retry(sessionId, mine, error);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
for (const record of mine)
|
|
73
|
+
this.delivering.delete(this.kind.id(record));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Marks every record the transcript proves; returns the ones it does not. */
|
|
77
|
+
async settle(records, session) {
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
for (const turn of await session.history()) {
|
|
80
|
+
if (turn.role !== "system" || turn.origin?.kind !== "task-callback")
|
|
81
|
+
continue;
|
|
82
|
+
for (const id of turn.origin.runIds ?? [turn.origin.runId])
|
|
83
|
+
seen.add(id);
|
|
84
|
+
}
|
|
85
|
+
const unproven = [];
|
|
86
|
+
for (const stale of records) {
|
|
87
|
+
const record = this.kind.reload(this.kind.id(stale)) ?? stale;
|
|
88
|
+
if (!seen.has(this.kind.id(record)))
|
|
89
|
+
unproven.push(record);
|
|
90
|
+
else if (record.callbackState !== "delivered")
|
|
91
|
+
this.delivered(record);
|
|
92
|
+
}
|
|
93
|
+
return unproven;
|
|
94
|
+
}
|
|
95
|
+
/** The recipient is waiting for an answer that is now late: the retry itself
|
|
96
|
+
* is silent, so this line is the only sign it is being retried — and the
|
|
97
|
+
* ceiling is what ends the retrying out loud. */
|
|
98
|
+
retry(sessionId, batch, error) {
|
|
99
|
+
log.warn(`callback to session ${sessionId} failed, will retry`, error);
|
|
100
|
+
for (const stale of batch) {
|
|
101
|
+
const record = this.kind.reload(this.kind.id(stale));
|
|
102
|
+
// Already proven delivered, or already given up on: not a failure.
|
|
103
|
+
if (!record || (record.callbackState !== "pending" && record.callbackState !== "failed"))
|
|
104
|
+
continue;
|
|
105
|
+
this.failed(record, error);
|
|
106
|
+
this.spent(record, sessionId);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Out of attempts: stop, record why, and report it. An agent waiting on a
|
|
110
|
+
* result it will never get must not be left waiting on silence. */
|
|
111
|
+
spent(record, sessionId) {
|
|
112
|
+
if (record.callbackAttempts < MAX_DELIVERY_ATTEMPTS)
|
|
113
|
+
return false;
|
|
114
|
+
if (record.callbackState !== "abandoned") {
|
|
115
|
+
record.callbackState = "abandoned";
|
|
116
|
+
record.callbackError = undeliverable(record.callbackAttempts, record.callbackError);
|
|
117
|
+
record.callbackNextAttemptAt = null;
|
|
118
|
+
this.write(record);
|
|
119
|
+
this.unreachable(sessionId, this.kind.describe(record), record.callbackError);
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
/** Busy target: try again shortly, and do not count it. */
|
|
124
|
+
defer(record) {
|
|
125
|
+
record.callbackNextAttemptAt = Date.now() + 1000;
|
|
126
|
+
this.kind.save(record);
|
|
127
|
+
}
|
|
128
|
+
/** Handed over, proof pending. Backs off like a failure, so an input the
|
|
129
|
+
* recipient never records is re-sent on a curve, not once a second. */
|
|
130
|
+
sent(record) {
|
|
131
|
+
record.callbackAttempts += 1;
|
|
132
|
+
record.callbackState = "pending";
|
|
133
|
+
record.callbackError = null;
|
|
134
|
+
record.callbackNextAttemptAt = Date.now() + retryDelay(record.callbackAttempts);
|
|
135
|
+
this.kind.save(record);
|
|
136
|
+
}
|
|
137
|
+
delivered(record) {
|
|
138
|
+
record.callbackState = "delivered";
|
|
139
|
+
record.callbackError = null;
|
|
140
|
+
record.callbackNextAttemptAt = null;
|
|
141
|
+
this.write(record);
|
|
142
|
+
}
|
|
143
|
+
/** Counts the attempt too: a target that fails before anything is sent (a
|
|
144
|
+
* transcript that no longer exists) would otherwise retry at attempt 0 for
|
|
145
|
+
* as long as the process lives, never reaching the ceiling. */
|
|
146
|
+
failed(record, error) {
|
|
147
|
+
record.callbackAttempts += 1;
|
|
148
|
+
record.callbackState = "failed";
|
|
149
|
+
record.callbackError = String(error);
|
|
150
|
+
record.callbackNextAttemptAt = Date.now() + retryDelay(record.callbackAttempts);
|
|
151
|
+
this.write(record);
|
|
152
|
+
}
|
|
153
|
+
write(record) {
|
|
154
|
+
this.kind.save(record);
|
|
155
|
+
this.kind.changed(record);
|
|
156
|
+
}
|
|
157
|
+
}
|