@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.
Files changed (67) hide show
  1. package/README.md +76 -12
  2. package/dist/agent/config.js +273 -27
  3. package/dist/agent/credentials.js +18 -12
  4. package/dist/agent/events.js +5 -41
  5. package/dist/agent/models.js +12 -0
  6. package/dist/agent/pi.js +182 -27
  7. package/dist/boards/boards.js +20 -10
  8. package/dist/channels/routes.js +1 -1
  9. package/dist/channels/runtime.js +36 -5
  10. package/dist/channels/slack-api.js +2 -4
  11. package/dist/channels/slack-outbound.js +4 -8
  12. package/dist/channels/slack-render.js +1 -4
  13. package/dist/channels/slack.js +20 -9
  14. package/dist/channels/telegram-api.js +3 -4
  15. package/dist/channels/telegram.js +37 -28
  16. package/dist/cli.js +177 -29
  17. package/dist/core/hub.js +36 -5
  18. package/dist/core/identity.js +5 -0
  19. package/dist/core/inbound-file.js +70 -0
  20. package/dist/core/inbox.js +32 -0
  21. package/dist/core/queue.js +9 -3
  22. package/dist/core/reply.js +20 -5
  23. package/dist/core/router.js +186 -14
  24. package/dist/core/types.js +53 -0
  25. package/dist/db.js +54 -8
  26. package/dist/drain.js +145 -0
  27. package/dist/main.js +86 -18
  28. package/dist/secrets.js +10 -6
  29. package/dist/service.js +142 -18
  30. package/dist/settings.js +69 -8
  31. package/dist/tasks/agent.js +41 -5
  32. package/dist/tasks/callbacks.js +29 -89
  33. package/dist/tasks/definitions.js +2 -6
  34. package/dist/tasks/execution.js +10 -1
  35. package/dist/tasks/groups.js +20 -49
  36. package/dist/tasks/messages.js +106 -21
  37. package/dist/tasks/outbox.js +157 -0
  38. package/dist/tasks/routes.js +6 -4
  39. package/dist/tasks/service.js +79 -22
  40. package/dist/tasks/store.js +48 -55
  41. package/dist/tasks/tool.js +19 -4
  42. package/dist/tasks/types.js +7 -0
  43. package/dist/update.js +94 -0
  44. package/dist/web/auth.js +75 -22
  45. package/dist/web/explorer.js +146 -0
  46. package/dist/web/files.js +26 -11
  47. package/dist/web/instance.js +99 -0
  48. package/dist/web/provider-flows.js +249 -0
  49. package/dist/web/providers.js +129 -0
  50. package/dist/web/public/assets/index-BK64pHmP.js +90 -0
  51. package/dist/web/public/assets/index-De4GlOq4.css +2 -0
  52. package/dist/web/public/icon-192.png +0 -0
  53. package/dist/web/public/icon-32.png +0 -0
  54. package/dist/web/public/icon-512.png +0 -0
  55. package/dist/web/public/icon-maskable-512.png +0 -0
  56. package/dist/web/public/icon-touch-192.png +0 -0
  57. package/dist/web/public/icon.svg +29 -11
  58. package/dist/web/public/index.html +43 -28
  59. package/dist/web/server.js +47 -120
  60. package/docs/deploy.md +120 -64
  61. package/package.json +1 -1
  62. package/skills/pier-help/SKILL.md +110 -0
  63. package/skills/pier-slack/SKILL.md +3 -2
  64. package/skills/pier-tasks/SKILL.md +19 -12
  65. package/dist/web/public/assets/index-8CinH1uR.css +0 -2
  66. package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
  67. package/dist/web/public/sw.js +0 -21
@@ -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 { logger } from "../log.js";
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; open run ${run.id}]`;
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
- router;
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.router = router;
27
- this.changed = changed;
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 and, in the same system input, every other
41
- * deliverable callback aimed at the same session: one model turn drains the
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
- // Ignore retry due-times when sweeping the batch: once one callback is
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).filter((run) => run.callbackSessionId === sessionId && !this.delivering.has(run.id));
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
- for (const run of batch)
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".
@@ -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")
@@ -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
- delivering = new Set();
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
- /** Same outbox semantics as run callbacks: busy defer, transcript dedupe on
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
- if (this.delivering.has(candidate.id))
130
+ const group = this.store.getGroup(candidate.id);
131
+ if (!group?.callbackSessionId || (group.callbackState !== "pending" && group.callbackState !== "failed"))
120
132
  return;
121
- this.delivering.add(candidate.id);
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));
@@ -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
- /** Retry schedule for failed injections. In-memory on purpose: a restart
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, limit = 200) {
58
- return this.store.listRecentMessages(since, limit);
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 ((this.retries.get(message.id)?.nextAt ?? 0) > now)
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
- * Delivery is therefore recorded on hand-off and corrected to `failed` by the
175
- * catch; the tick sweep retries from there. */
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
- void this.inject(message, run, targetSessionId, this.mode(message))
188
- .catch((error) => { this.failed(message.id, error); });
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
- if (!message || message.state === "answered" || message.state === "expired")
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
- const attempts = (this.retries.get(id)?.attempts ?? 0) + 1;
207
- this.retries.set(id, { attempts, nextAt: Date.now() + Math.min(60_000, 1000 * 2 ** Math.min(attempts, 6)) });
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
- const session = await this.router.ensure({ channelId: "task", conversationId: targetSessionId });
211
- const delivered = (await session.history()).some((turn) => turn.role === "system" && turn.origin?.kind === "task-message" && turn.origin.messageId === message.id);
212
- if (delivered)
213
- return;
214
- await session.systemInput(this.format(message, run), this.origin(message, run), mode === "follow_up" ? "followUp" : mode);
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
+ }