@timqi/pier 0.0.29 → 0.1.1

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 (165) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +24 -11
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +43 -62
  5. package/dist/agent/listing.js +113 -68
  6. package/dist/agent/pi.js +204 -211
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +8 -24
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +13 -35
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -260
  48. package/dist/core/types.js +17 -1
  49. package/dist/db.js +98 -252
  50. package/dist/drain.js +58 -51
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +6 -14
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +87 -179
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +19 -46
  65. package/dist/service.js +33 -75
  66. package/dist/settings.js +42 -65
  67. package/dist/tasks/agent.js +129 -114
  68. package/dist/tasks/callbacks.js +9 -19
  69. package/dist/tasks/command.js +29 -14
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +46 -42
  72. package/dist/tasks/groups.js +41 -35
  73. package/dist/tasks/messages.js +121 -182
  74. package/dist/tasks/outbox.js +61 -55
  75. package/dist/tasks/routes.js +5 -11
  76. package/dist/tasks/runs.js +14 -13
  77. package/dist/tasks/service.js +53 -54
  78. package/dist/tasks/store.js +53 -30
  79. package/dist/tasks/tool.js +132 -61
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +100 -327
  82. package/dist/update.js +21 -44
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +45 -85
  89. package/dist/web/providers.js +14 -13
  90. package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
  91. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  92. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  93. package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
  94. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  95. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
  97. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  98. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  99. package/dist/web/public/assets/index-DzXDXra_.js +85 -0
  100. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  101. package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/runs-Cwy0mN8i.js +1 -0
  106. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  108. package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
  109. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  110. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  111. package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
  112. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
  115. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
  117. package/dist/web/public/index.html +100 -130
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/manifest.webmanifest +2 -2
  121. package/dist/web/public/manifest.webmanifest.br +0 -0
  122. package/dist/web/public/manifest.webmanifest.gz +0 -0
  123. package/dist/web/public/sw.js +14 -2
  124. package/dist/web/public/sw.js.br +0 -0
  125. package/dist/web/public/sw.js.gz +0 -0
  126. package/dist/web/push.js +55 -77
  127. package/dist/web/route.js +3 -7
  128. package/dist/web/server.js +131 -180
  129. package/dist/web/session-state.js +14 -54
  130. package/dist/web/types.js +2 -4
  131. package/dist/web/webpush.js +10 -25
  132. package/docs/deploy.md +115 -330
  133. package/package.json +2 -1
  134. package/skills/pier-boards/SKILL.md +81 -160
  135. package/skills/pier-help/SKILL.md +23 -20
  136. package/skills/pier-slack/SKILL.md +2 -2
  137. package/skills/pier-tasks/SKILL.md +153 -160
  138. package/dist/config-sync-fetch.js +0 -84
  139. package/dist/limits.js +0 -14
  140. package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
  141. package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
  142. package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
  143. package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
  144. package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
  145. package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
  146. package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
  147. package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
  148. package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
  149. package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
  150. package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
  151. package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
  152. package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
  153. package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
  154. package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
  155. package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
  156. package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
  157. package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
  158. package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
  159. package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
  160. package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
  161. package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
  162. package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
  163. package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
  164. package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
  165. package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
@@ -1,18 +1,16 @@
1
- // What a parent and a child say to each other while a run is going: steer,
2
- // follow-up and resume in one direction, progress and decision questions in
3
- // the other. Every message is a durable row before it is a delivery, because
4
- // the two ends are different sessions and either may be mid-turn, gone, or
5
- // finished — an undelivered message is retried, expired and *said*, never
6
- // dropped (§5b).
7
- import { EventHub } from "../core/hub.js";
8
- import { Router } from "../core/router.js";
9
- import { logger } from "../log.js";
1
+ // What a parent and a child say to each other while a run is going. Every
2
+ // message is a durable row before it is a delivery: either end may be mid-turn
3
+ // or gone, and an undelivered message is retried, expired and said, never
4
+ // dropped (§5). Delivery itself belongs to outbox.ts.
10
5
  import { runSource } from "./callbacks.js";
11
6
  import { newId } from "./definitions.js";
12
- import { TaskStore } from "./store.js";
13
- import { isTerminal, MAX_DELIVERY_ATTEMPTS, retryDelay, undeliverable } from "./types.js";
14
- const log = logger("tasks");
7
+ import { Outbox } from "./outbox.js";
8
+ import { isTerminal } from "./types.js";
15
9
  const MAX_MESSAGE_LENGTH = 16 * 1024;
10
+ /** `answered` is a delivery that was read; `expired` is one given up on. */
11
+ const ENGINE_STATE = {
12
+ pending: "pending", failed: "failed", delivered: "delivered", answered: "delivered", expired: "abandoned",
13
+ };
16
14
  function bounded(content) {
17
15
  const text = content.trim();
18
16
  if (!text)
@@ -23,20 +21,56 @@ function bounded(content) {
23
21
  }
24
22
  export class TaskMessenger {
25
23
  store;
26
- router;
27
24
  hub;
28
- resumeRun;
25
+ prepareResume;
29
26
  unreachable;
27
+ startRun;
28
+ outbox;
30
29
  constructor(store, router, hub,
31
- /** Continues a terminal child with a supervisor reply as its prompt. */
32
- resumeRun,
30
+ /** Prepares a continuation; it starts only after the reply commits. */
31
+ prepareResume,
33
32
  /** Reports a delivery nobody can complete (service.ts owns the surfaces). */
34
- unreachable) {
33
+ unreachable, startRun) {
35
34
  this.store = store;
36
- this.router = router;
37
35
  this.hub = hub;
38
- this.resumeRun = resumeRun;
36
+ this.prepareResume = prepareResume;
39
37
  this.unreachable = unreachable;
38
+ this.startRun = startRun;
39
+ this.outbox = new Outbox(router, {
40
+ id: ({ message }) => message.id,
41
+ reload: (id) => {
42
+ const message = this.store.getMessage(id);
43
+ return message && this.carry(message);
44
+ },
45
+ save: ({ message, callbackState, callbackAttempts, callbackError, callbackNextAttemptAt }) => {
46
+ message.attempts = callbackAttempts;
47
+ message.error = callbackError;
48
+ message.nextAttemptAt = callbackNextAttemptAt;
49
+ if (callbackState === "delivered") {
50
+ message.state = "delivered";
51
+ message.deliveredAt = Date.now();
52
+ }
53
+ else if (callbackState === "abandoned") {
54
+ message.state = "expired";
55
+ message.answeredAt = Date.now();
56
+ }
57
+ else
58
+ message.state = callbackState ?? "pending";
59
+ this.store.saveMessage(message);
60
+ },
61
+ changed: ({ message }) => this.changed(message),
62
+ input: (records) => {
63
+ const message = records[0].message;
64
+ const run = this.store.getRun(message.runId);
65
+ if (!run)
66
+ throw new Error(`unknown task run: ${message.runId}`);
67
+ return { text: this.format(message, run), origin: this.origin(message, run) };
68
+ },
69
+ abandoned: ({ message }, _sessionId, why) => this.told(message, why),
70
+ // A follow-up joins the recipient's queue now: the run it guides may end
71
+ // with the turn it would otherwise wait out.
72
+ queues: true,
73
+ });
40
74
  }
41
75
  expirePending() {
42
76
  for (const message of this.store.expirePendingMessages())
@@ -49,6 +83,7 @@ export class TaskMessenger {
49
83
  /** A manual continuation supersedes an unanswered decision (design 04):
50
84
  * one continuation per run, never two racing ones. */
51
85
  expireDecisions(runId, reason) {
86
+ const expired = [];
52
87
  for (const message of this.store.listMessages(runId)) {
53
88
  if (message.kind !== "decision" || (message.state !== "pending" && message.state !== "delivered"))
54
89
  continue;
@@ -56,8 +91,9 @@ export class TaskMessenger {
56
91
  message.error = reason;
57
92
  message.answeredAt = Date.now();
58
93
  this.store.saveMessage(message);
59
- this.changed(message);
94
+ expired.push(message);
60
95
  }
96
+ return expired;
61
97
  }
62
98
  list(runId) {
63
99
  return this.store.listMessages(runId);
@@ -67,8 +103,9 @@ export class TaskMessenger {
67
103
  }
68
104
  async control(run, fromSessionId, kind, content) {
69
105
  const message = this.create(run, kind, fromSessionId, run.targetSessionId ?? "", content, null);
106
+ this.changed(message);
70
107
  if (run.targetSessionId)
71
- this.deliver(message, run, run.targetSessionId);
108
+ this.deliver(message, run.targetSessionId);
72
109
  return this.require(message.id);
73
110
  }
74
111
  deliverPendingControls(run) {
@@ -77,12 +114,11 @@ export class TaskMessenger {
77
114
  for (const message of this.store.listMessages(run.id)) {
78
115
  if (message.state !== "pending" || (message.kind !== "steer" && message.kind !== "follow_up"))
79
116
  continue;
80
- this.deliver(message, run, run.targetSessionId);
117
+ this.deliver(message, run.targetSessionId);
81
118
  }
82
119
  }
83
- /** Injection is fire-and-forget, so this sweep is what closes a failed one.
84
- * `inject` dedupes on the recipient transcript, so a retry cannot double
85
- * deliver. Controls aimed at a finished run are dead and expire here. */
120
+ /** Delivery is fire-and-forget, so this sweep is what closes a failed one.
121
+ * Controls aimed at a finished run are dead and expire here. */
86
122
  retryUndelivered(now = Date.now()) {
87
123
  for (const { message, run } of this.store.listUndeliveredMessages()) {
88
124
  if (!run)
@@ -95,26 +131,19 @@ export class TaskMessenger {
95
131
  this.changed(message);
96
132
  continue;
97
133
  }
98
- // A reply that resumed a terminal run was never a system input: its text
99
- // is the continuation's prompt, so the transcript read below would find
100
- // nothing and re-inject a second copy, starting a turn no run owns. The
101
- // continuation is the proof instead. `startedAt` is written one statement
102
- // before the prompt reaches the session (agent.ts), so `delivered` here
103
- // means "handed to the run that reports for it" — a `systemInput` that
104
- // then throws fails *that* run, and the failure reaches the replier
105
- // through its callback.
134
+ // A reply that resumed a terminal run is the continuation's prompt, not a
135
+ // system input: the transcript read would re-inject it and start a turn no
136
+ // run owns. The continuation's `startedAt` is the proof instead.
106
137
  if (message.kind === "reply" && isTerminal(run.state)) {
107
138
  const resumed = message.resumeRunId ? this.store.getRun(message.resumeRunId) : undefined;
108
139
  if (resumed?.startedAt)
109
- this.confirmed(message.id);
140
+ this.delivered(message);
110
141
  else if (!resumed)
111
142
  this.abandon(message, `continuation ${message.resumeRunId ?? "(none)"} is gone`);
112
143
  else if (isTerminal(resumed.state)) {
113
- // Never started, and never will: say so rather than wait for a
114
- // ceiling that would only report the same thing four minutes later.
115
- // Both ends hear it here; the supervisor may also get the
116
- // continuation's own cancelled callback, and a duplicate beats a
117
- // special case that could suppress the only report either gets.
144
+ // Never started and never will: said now, not four minutes later at
145
+ // the ceiling. A duplicate report beats a special case that could
146
+ // suppress the only one.
118
147
  this.abandon(message, `continuation ${resumed.id} ${resumed.state} before it started`);
119
148
  }
120
149
  continue;
@@ -123,15 +152,12 @@ export class TaskMessenger {
123
152
  continue;
124
153
  const target = message.toSessionId || run.targetSessionId;
125
154
  if (target)
126
- this.deliver(message, run, target);
155
+ this.deliver(message, target);
127
156
  }
128
157
  }
129
- /** Asynchronous by design: returns the receipt immediately. A decision
130
- * child states what it awaits and ends its turn; the reply arrives as a
131
- * follow-up (active run) or resumes the session (terminal run).
132
- * A decision steers the supervisor: a follow-up only lands once the
133
- * supervisor has no tool calls left, so a blocked child would wait out the
134
- * whole turn. Progress stays a follow-up — nobody waits on it. */
158
+ /** Returns the receipt immediately; the reply arrives as a follow-up (active
159
+ * run) or resumes the session (terminal run). A decision steers the
160
+ * supervisor, or a blocked child would wait out its whole turn. */
135
161
  async contact(run, fromSessionId, reason, content) {
136
162
  if (!run.invokedBySessionId)
137
163
  throw new Error("run has no supervisor session");
@@ -139,7 +165,8 @@ export class TaskMessenger {
139
165
  throw new Error("run already has a pending supervisor decision");
140
166
  }
141
167
  const message = this.create(run, reason, fromSessionId, run.invokedBySessionId, content, null);
142
- this.deliver(message, run, run.invokedBySessionId);
168
+ this.changed(message);
169
+ this.deliver(message, run.invokedBySessionId);
143
170
  return this.require(message.id);
144
171
  }
145
172
  async reply(questionId, fromSessionId, content) {
@@ -161,25 +188,28 @@ export class TaskMessenger {
161
188
  const run = this.store.getRun(question.runId);
162
189
  if (!run)
163
190
  throw new Error(`unknown task run: ${question.runId}`);
164
- const reply = this.create(run, "reply", fromSessionId, question.fromSessionId, text, question.id);
165
- question.state = "answered";
166
- question.answeredAt = Date.now();
167
- this.store.saveMessage(question);
191
+ // A rejected continuation must leave the question answerable. Persist the
192
+ // answer and its queued run together, before either publishes or executes.
193
+ const { reply, continuation } = this.store.transact(() => {
194
+ const reply = this.create(run, "reply", fromSessionId, question.fromSessionId, text, question.id);
195
+ question.state = "answered";
196
+ question.answeredAt = Date.now();
197
+ this.store.saveMessage(question);
198
+ const continuation = isTerminal(run.state)
199
+ ? this.prepareResume(run.id, this.format(reply, run), fromSessionId)
200
+ : undefined;
201
+ if (continuation) {
202
+ reply.resumeRunId = continuation.id;
203
+ this.store.saveMessage(reply);
204
+ }
205
+ return { reply, continuation };
206
+ });
168
207
  this.changed(question);
169
- // Core routes the reply: follow-up into an active run, auto-resume of a
170
- // terminal one — the replier gets the continuation's callback.
171
- if (run.targetSessionId && (run.state === "queued" || run.state === "running")) {
172
- this.deliver(reply, run, run.targetSessionId);
173
- }
174
- else if (isTerminal(run.state)) {
175
- // Creating the continuation is not delivering the reply: the run is
176
- // queued, and a restart or a cancel before it starts loses the prompt
177
- // that carries the text. So the id is recorded and the sweep below
178
- // settles it against the continuation's own `startedAt`.
179
- reply.resumeRunId = this.resumeRun(run.id, this.format(reply, run), fromSessionId).id;
180
- this.store.saveMessage(reply);
181
- this.changed(reply);
182
- }
208
+ this.changed(reply);
209
+ if (continuation)
210
+ this.startRun(continuation);
211
+ else if (run.targetSessionId)
212
+ this.deliver(reply, run.targetSessionId);
183
213
  return this.require(reply.id);
184
214
  }
185
215
  create(run, kind, fromSessionId, toSessionId, content, replyTo) {
@@ -200,33 +230,30 @@ export class TaskMessenger {
200
230
  nextAttemptAt: null,
201
231
  };
202
232
  this.store.saveMessage(message);
203
- this.changed(message);
204
233
  return message;
205
234
  }
206
- /** Never awaits the recipient: the seam's `systemInput` settles with the turn
207
- * the input triggers, so awaiting it would block the sender — a child's
208
- * `contact` on its supervisor's whole answer turn — which the design forbids.
209
- * So `delivered` is written by `confirmed`, against the one proof that
210
- * survives an abort or a restart: the message visible in the recipient's own
211
- * transcript. Until then it stays pending and the sweep tries again. */
212
- deliver(candidate, run, targetSessionId) {
213
- const message = this.require(candidate.id);
214
- if (message.state !== "pending" && message.state !== "failed")
215
- return;
216
- if (message.toSessionId !== targetSessionId)
235
+ /** A control created before its run had a session is aimed once it has one. */
236
+ deliver(message, targetSessionId) {
237
+ if (message.toSessionId !== targetSessionId) {
217
238
  message.toSessionId = targetSessionId;
218
- message.state = "pending";
219
- message.error = null;
220
- this.store.saveMessage(message);
221
- this.changed(message);
222
- void this.inject(message, run, targetSessionId, this.mode(message))
223
- .catch((error) => log.error(`message ${message.id} delivery collapsed`, error));
239
+ this.store.saveMessage(message);
240
+ }
241
+ void this.outbox.deliver(targetSessionId, [this.carry(message)]);
224
242
  }
225
- /** The one place a message becomes delivered. */
226
- confirmed(id) {
227
- const message = this.store.getMessage(id);
228
- if (!message || message.state !== "pending")
229
- return;
243
+ carry(message) {
244
+ return {
245
+ message,
246
+ callbackState: ENGINE_STATE[message.state],
247
+ callbackAttempts: message.attempts,
248
+ callbackError: message.error,
249
+ callbackNextAttemptAt: message.nextAttemptAt,
250
+ // Steer whatever someone is blocked on: a follow-up lands only once the
251
+ // recipient runs out of tool calls. Progress is a follow-up because nobody waits.
252
+ ...(message.kind === "follow_up" || message.kind === "progress" ? {} : { callbackMode: "steer" }),
253
+ };
254
+ }
255
+ /** Proven by something other than the recipient's transcript. */
256
+ delivered(message) {
230
257
  message.state = "delivered";
231
258
  message.deliveredAt = Date.now();
232
259
  message.error = null;
@@ -234,37 +261,6 @@ export class TaskMessenger {
234
261
  this.store.saveMessage(message);
235
262
  this.changed(message);
236
263
  }
237
- /** Waiting is not a failed attempt: the message is in flight or the turn it
238
- * has to wait for is still running, so it is tried again shortly and the
239
- * ceiling is left for the deliveries that actually failed (the rule
240
- * outbox.ts keeps for callbacks). */
241
- defer(id) {
242
- const message = this.store.getMessage(id);
243
- if (!message || message.state !== "pending")
244
- return;
245
- message.nextAttemptAt = Date.now() + 1000;
246
- this.store.saveMessage(message);
247
- }
248
- /** Counts one hand-off and says whether it may happen: a recipient that
249
- * never records the message must not be re-sent once a second forever.
250
- * False means the ceiling was reached and the message is now expired. */
251
- spend(id) {
252
- const message = this.store.getMessage(id);
253
- if (!message || message.state !== "pending")
254
- return false;
255
- message.attempts += 1;
256
- message.nextAttemptAt = Date.now() + retryDelay(message.attempts);
257
- if (message.attempts > MAX_DELIVERY_ATTEMPTS) {
258
- this.abandon(message, undeliverable(message.attempts - 1, message.error));
259
- return false;
260
- }
261
- this.store.saveMessage(message);
262
- return true;
263
- }
264
- /** Out of attempts. Both ends are told — the recipient that was owed it and
265
- * the sender waiting on the answer — and a decision that expires here stops
266
- * suppressing its run's completion callback, which `execution.ts` decided
267
- * once, at the end of the run, and never revisits. */
268
264
  abandon(message, why) {
269
265
  message.state = "expired";
270
266
  message.error = why;
@@ -272,6 +268,11 @@ export class TaskMessenger {
272
268
  message.nextAttemptAt = null;
273
269
  this.store.saveMessage(message);
274
270
  this.changed(message);
271
+ this.told(message, why);
272
+ }
273
+ /** Both ends are told, and an expired decision stops suppressing its run's
274
+ * completion callback, which `execution.ts` decided once and never revisits. */
275
+ told(message, why) {
275
276
  this.unreachable(message.toSessionId, `a ${message.kind} from run ${message.runId}`, why);
276
277
  if (message.fromSessionId && message.fromSessionId !== message.toSessionId) {
277
278
  this.unreachable(message.fromSessionId, `your ${message.kind} on run ${message.runId}`, why);
@@ -284,69 +285,6 @@ export class TaskMessenger {
284
285
  this.store.saveRun(run);
285
286
  }
286
287
  }
287
- /** Steer whatever someone is blocked on: a decision, and the reply that
288
- * answers it — a follow-up lands only once the recipient runs out of tool
289
- * calls, so each would wait out a whole turn (the reply waited 3 minutes
290
- * behind one on 2026-09-07). Progress is a follow-up because nobody waits. */
291
- mode(message) {
292
- return message.kind === "follow_up" || message.kind === "progress" ? "follow_up" : "steer";
293
- }
294
- failed(id, error, spent) {
295
- const message = this.store.getMessage(id);
296
- // Only a still-pending message can fail: a late rejection must not undo a
297
- // delivery a newer attempt already proved, nor revive an expired one.
298
- if (message?.state !== "pending")
299
- return;
300
- // Both ends are waiting on this one: the sender for an answer, the
301
- // recipient for a message it never got told about.
302
- log.warn(`${message.kind} ${id} to session ${message.toSessionId} failed`, error);
303
- message.state = "failed";
304
- message.error = String(error);
305
- // A pass that died before the send never spent its attempt — an
306
- // unresolvable target dies there every time, and would retry forever.
307
- if (!spent)
308
- message.attempts += 1;
309
- message.nextAttemptAt = Date.now() + retryDelay(message.attempts);
310
- this.store.saveMessage(message);
311
- this.changed(message);
312
- if (message.attempts > MAX_DELIVERY_ATTEMPTS) {
313
- this.abandon(message, undeliverable(message.attempts - 1, message.error));
314
- }
315
- }
316
- /** One pass at getting the message into the recipient: the dedupe on a retry
317
- * and the proof of delivery are the same transcript read. Owns its own
318
- * failure, so an attempt is counted exactly once whether the pass died
319
- * before the send or the send itself was refused. */
320
- async inject(message, run, targetSessionId, mode) {
321
- let spent = false;
322
- try {
323
- const session = await this.router.ensure({ channelId: "task", conversationId: targetSessionId });
324
- const recorded = async () => (await session.history()).some((turn) => turn.role === "system" && turn.origin?.kind === "task-message" && turn.origin.messageId === message.id);
325
- if (await recorded())
326
- return this.confirmed(message.id);
327
- // Accepted and waiting in the recipient's queue for the running turn to
328
- // drain it: not recorded yet, and sending again would deliver the same
329
- // guidance twice. Unlike a callback this is not deferred on a busy
330
- // target — a steer's whole point is to reach the turn already running —
331
- // so the queue is where it sits, and waiting for it costs no attempt.
332
- if (await this.queued(session, message.id))
333
- return this.defer(message.id);
334
- spent = this.spend(message.id);
335
- if (!spent)
336
- return;
337
- await session.systemInput(this.format(message, run), this.origin(message, run), mode === "follow_up" ? "followUp" : mode);
338
- if (await recorded())
339
- this.confirmed(message.id);
340
- }
341
- catch (error) {
342
- this.failed(message.id, error, spent);
343
- }
344
- }
345
- /** In the recipient's queue, by the id its origin carries — the transcript
346
- * read above answers "landed", this answers "handed over and waiting". */
347
- async queued(session, id) {
348
- return (await session.pendingSystemInputs()).some((origin) => origin.kind === "task-message" && origin.messageId === id);
349
- }
350
288
  format(message, run) {
351
289
  const title = run.context.definition.name;
352
290
  if (message.kind === "progress") {
@@ -377,6 +315,7 @@ export class TaskMessenger {
377
315
  throw new Error(`unknown task message: ${id}`);
378
316
  return message;
379
317
  }
318
+ /** Publish only after any transaction changing this message has committed. */
380
319
  changed(message) {
381
320
  this.hub.emitWorkspace({ type: "task-message-changed", runId: message.runId, messageId: message.id });
382
321
  }
@@ -1,40 +1,35 @@
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";
1
+ // One delivery engine for everything that reaches a session as a system input:
2
+ // run callbacks, group callbacks and run messages.
7
3
  import { logger } from "../log.js";
8
4
  import { MAX_DELIVERY_ATTEMPTS, retryDelay, undeliverable } from "./types.js";
9
5
  const log = logger("tasks");
6
+ /** The records a delivery names, from either side of the seam: a batch names
7
+ * every one of them, a group names itself, a message names its id. */
8
+ const recordIds = (origin) => {
9
+ if (origin?.kind === "task-callback")
10
+ return origin.runIds ?? [origin.runId];
11
+ if (origin?.kind === "task-message")
12
+ return [origin.messageId];
13
+ return [];
14
+ };
10
15
  export class Outbox {
11
16
  router;
12
17
  kind;
13
- unreachable;
14
18
  delivering = new Set();
15
- constructor(router, kind,
16
- /** Reports a delivery nobody can complete (service.ts owns the surfaces). */
17
- unreachable) {
19
+ constructor(router, kind) {
18
20
  this.router = router;
19
21
  this.kind = kind;
20
- this.unreachable = unreachable;
21
22
  }
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
- */
23
+ /** One model turn drains the batch instead of one per record. `delivered` is
24
+ * written only against the input visible in the recipient's transcript: Pi's
25
+ * queues are memory, so a resolved `systemInput` proves nothing. */
32
26
  async deliver(sessionId, batch) {
33
27
  const mine = batch.filter((record) => !this.delivering.has(this.kind.id(record)));
34
28
  if (mine.length === 0)
35
29
  return;
36
30
  for (const record of mine)
37
31
  this.delivering.add(this.kind.id(record));
32
+ const counted = new Set();
38
33
  try {
39
34
  const session = await this.router.ensure({ channelId: "task", conversationId: sessionId });
40
35
  // The transcript read is both the crash-window dedupe and the proof.
@@ -44,42 +39,56 @@ export class Outbox {
44
39
  const live = unproven.filter((record) => !this.spent(record, sessionId));
45
40
  if (live.length === 0)
46
41
  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.
42
+ let sending = live;
43
+ // Waiting for a busy target is not an attempt, or the ceiling arrives in
44
+ // seconds. A `steer` record joins the running turn instead, but only once:
45
+ // handed over, it sits in Pi's in-memory queue, invisible in the transcript.
50
46
  if (session.state === "streaming") {
47
+ const handedOver = await this.queued(session);
48
+ const sendNow = (record) => (record.callbackMode === "steer" || this.kind.queues === true) && !handedOver.has(this.kind.id(record));
49
+ sending = live.filter(sendNow);
51
50
  for (const record of live)
52
- this.defer(record);
53
- return;
51
+ if (!sendNow(record))
52
+ this.defer(record);
53
+ if (sending.length === 0)
54
+ return;
54
55
  }
55
- for (const record of live)
56
+ for (const record of sending) {
56
57
  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);
58
+ counted.add(this.kind.id(record));
59
+ }
60
+ const { text, origin } = this.kind.input(sending);
61
+ const mode = sending.every((record) => record.callbackMode === "steer") ? "steer" : "followUp";
62
+ log.debug(`callback for ${sending.map((r) => this.kind.id(r)).join(", ")} → session ${sessionId}`);
63
+ // Not awaited: `systemInput` settles with the recipient's whole turn.
64
+ session.systemInput(text, origin, mode)
65
+ .catch((error) => this.retry(sessionId, sending, error, counted));
66
+ // Pi records the input as it starts the turn; the tick sweep is the backstop.
67
+ await this.settle(sending, session);
67
68
  }
68
69
  catch (error) {
69
- this.retry(sessionId, mine, error);
70
+ this.retry(sessionId, mine, error, counted);
70
71
  }
71
72
  finally {
72
73
  for (const record of mine)
73
74
  this.delivering.delete(this.kind.id(record));
74
75
  }
75
76
  }
77
+ /** What the transcript cannot answer yet; empty unless the session is streaming. */
78
+ async queued(session) {
79
+ const ids = new Set();
80
+ for (const origin of await session.pendingSystemInputs())
81
+ for (const id of recordIds(origin))
82
+ ids.add(id);
83
+ return ids;
84
+ }
76
85
  /** Marks every record the transcript proves; returns the ones it does not. */
77
86
  async settle(records, session) {
78
87
  const seen = new Set();
79
88
  for (const turn of await session.history()) {
80
- if (turn.role !== "system" || turn.origin?.kind !== "task-callback")
89
+ if (turn.role !== "system")
81
90
  continue;
82
- for (const id of turn.origin.runIds ?? [turn.origin.runId])
91
+ for (const id of recordIds(turn.origin))
83
92
  seen.add(id);
84
93
  }
85
94
  const unproven = [];
@@ -92,22 +101,20 @@ export class Outbox {
92
101
  }
93
102
  return unproven;
94
103
  }
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) {
104
+ /** The retry itself is silent, so this line is the only sign of it. */
105
+ retry(sessionId, batch, error, counted) {
99
106
  log.warn(`callback to session ${sessionId} failed, will retry`, error);
100
107
  for (const stale of batch) {
101
108
  const record = this.kind.reload(this.kind.id(stale));
102
109
  // Already proven delivered, or already given up on: not a failure.
103
110
  if (!record || (record.callbackState !== "pending" && record.callbackState !== "failed"))
104
111
  continue;
105
- this.failed(record, error);
112
+ this.failed(record, error, counted.has(this.kind.id(record)));
113
+ counted.add(this.kind.id(record));
106
114
  this.spent(record, sessionId);
107
115
  }
108
116
  }
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. */
117
+ /** An agent waiting on a result it will never get must not wait on silence. */
111
118
  spent(record, sessionId) {
112
119
  if (record.callbackAttempts < MAX_DELIVERY_ATTEMPTS)
113
120
  return false;
@@ -116,7 +123,7 @@ export class Outbox {
116
123
  record.callbackError = undeliverable(record.callbackAttempts, record.callbackError);
117
124
  record.callbackNextAttemptAt = null;
118
125
  this.write(record);
119
- this.unreachable(sessionId, this.kind.describe(record), record.callbackError);
126
+ this.kind.abandoned(record, sessionId, record.callbackError);
120
127
  }
121
128
  return true;
122
129
  }
@@ -125,8 +132,8 @@ export class Outbox {
125
132
  record.callbackNextAttemptAt = Date.now() + 1000;
126
133
  this.kind.save(record);
127
134
  }
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. */
135
+ /** Backs off like a failure, so an input never recorded is re-sent on a
136
+ * curve, not once a second. */
130
137
  sent(record) {
131
138
  record.callbackAttempts += 1;
132
139
  record.callbackState = "pending";
@@ -140,11 +147,10 @@ export class Outbox {
140
147
  record.callbackNextAttemptAt = null;
141
148
  this.write(record);
142
149
  }
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;
150
+ /** A failed pass costs one attempt, whether it died before or after send. */
151
+ failed(record, error, counted) {
152
+ if (!counted)
153
+ record.callbackAttempts += 1;
148
154
  record.callbackState = "failed";
149
155
  record.callbackError = String(error);
150
156
  record.callbackNextAttemptAt = Date.now() + retryDelay(record.callbackAttempts);