@timqi/pier 0.1.0 → 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 (158) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +6 -15
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +42 -64
  5. package/dist/agent/listing.js +39 -92
  6. package/dist/agent/pi.js +103 -252
  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 +7 -23
  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 +12 -34
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -264
  48. package/dist/core/types.js +4 -0
  49. package/dist/db.js +88 -272
  50. package/dist/drain.js +57 -50
  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 +4 -12
  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 +84 -183
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +18 -45
  65. package/dist/service.js +31 -73
  66. package/dist/settings.js +19 -63
  67. package/dist/tasks/agent.js +24 -45
  68. package/dist/tasks/callbacks.js +8 -16
  69. package/dist/tasks/command.js +2 -6
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +41 -39
  72. package/dist/tasks/groups.js +8 -11
  73. package/dist/tasks/messages.js +88 -155
  74. package/dist/tasks/outbox.js +33 -54
  75. package/dist/tasks/routes.js +4 -7
  76. package/dist/tasks/runs.js +4 -9
  77. package/dist/tasks/service.js +29 -42
  78. package/dist/tasks/store.js +36 -27
  79. package/dist/tasks/tool.js +59 -60
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +98 -325
  82. package/dist/update.js +20 -43
  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 +35 -82
  89. package/dist/web/providers.js +5 -11
  90. package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-B89_hH7q.js} +1 -1
  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-DYuf4Mlj.js → boards-BeKW0ZXK.js} +1 -1
  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-BLJu7EXN.js → runs-Cwy0mN8i.js} +1 -1
  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-BrdVh-Zi.js → settings-DzZLmujq.js} +1 -1
  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-CeQS1rxa.js → task-runs-BCakxFk8.js} +1 -1
  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 +30 -16
  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/sw.js +14 -2
  121. package/dist/web/public/sw.js.br +0 -0
  122. package/dist/web/public/sw.js.gz +0 -0
  123. package/dist/web/push.js +55 -77
  124. package/dist/web/route.js +3 -7
  125. package/dist/web/server.js +109 -190
  126. package/dist/web/session-state.js +13 -53
  127. package/dist/web/types.js +2 -4
  128. package/dist/web/webpush.js +10 -25
  129. package/docs/deploy.md +115 -330
  130. package/package.json +1 -1
  131. package/skills/pier-boards/SKILL.md +81 -160
  132. package/skills/pier-help/SKILL.md +23 -20
  133. package/skills/pier-slack/SKILL.md +2 -2
  134. package/skills/pier-tasks/SKILL.md +23 -15
  135. package/dist/config-sync-fetch.js +0 -84
  136. package/dist/limits.js +0 -14
  137. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  138. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  139. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  140. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  141. package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
  142. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  143. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  144. package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
  145. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  146. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  147. package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
  148. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  149. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  150. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  151. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  152. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  153. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  154. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  155. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  156. package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
  157. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  158. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
@@ -1,15 +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 { 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.
8
5
  import { runSource } from "./callbacks.js";
9
6
  import { newId } from "./definitions.js";
10
- import { isTerminal, MAX_DELIVERY_ATTEMPTS, retryDelay, undeliverable } from "./types.js";
11
- const log = logger("tasks");
7
+ import { Outbox } from "./outbox.js";
8
+ import { isTerminal } from "./types.js";
12
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
+ };
13
14
  function bounded(content) {
14
15
  const text = content.trim();
15
16
  if (!text)
@@ -20,22 +21,56 @@ function bounded(content) {
20
21
  }
21
22
  export class TaskMessenger {
22
23
  store;
23
- router;
24
24
  hub;
25
25
  prepareResume;
26
26
  unreachable;
27
27
  startRun;
28
+ outbox;
28
29
  constructor(store, router, hub,
29
30
  /** Prepares a continuation; it starts only after the reply commits. */
30
31
  prepareResume,
31
32
  /** Reports a delivery nobody can complete (service.ts owns the surfaces). */
32
33
  unreachable, startRun) {
33
34
  this.store = store;
34
- this.router = router;
35
35
  this.hub = hub;
36
36
  this.prepareResume = prepareResume;
37
37
  this.unreachable = unreachable;
38
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
+ });
39
74
  }
40
75
  expirePending() {
41
76
  for (const message of this.store.expirePendingMessages())
@@ -70,7 +105,7 @@ export class TaskMessenger {
70
105
  const message = this.create(run, kind, fromSessionId, run.targetSessionId ?? "", content, null);
71
106
  this.changed(message);
72
107
  if (run.targetSessionId)
73
- this.deliver(message, run, run.targetSessionId);
108
+ this.deliver(message, run.targetSessionId);
74
109
  return this.require(message.id);
75
110
  }
76
111
  deliverPendingControls(run) {
@@ -79,12 +114,11 @@ export class TaskMessenger {
79
114
  for (const message of this.store.listMessages(run.id)) {
80
115
  if (message.state !== "pending" || (message.kind !== "steer" && message.kind !== "follow_up"))
81
116
  continue;
82
- this.deliver(message, run, run.targetSessionId);
117
+ this.deliver(message, run.targetSessionId);
83
118
  }
84
119
  }
85
- /** Injection is fire-and-forget, so this sweep is what closes a failed one.
86
- * `inject` dedupes on the recipient transcript, so a retry cannot double
87
- * 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. */
88
122
  retryUndelivered(now = Date.now()) {
89
123
  for (const { message, run } of this.store.listUndeliveredMessages()) {
90
124
  if (!run)
@@ -97,26 +131,19 @@ export class TaskMessenger {
97
131
  this.changed(message);
98
132
  continue;
99
133
  }
100
- // A reply that resumed a terminal run was never a system input: its text
101
- // is the continuation's prompt, so the transcript read below would find
102
- // nothing and re-inject a second copy, starting a turn no run owns. The
103
- // continuation is the proof instead. `startedAt` is written one statement
104
- // before the prompt reaches the session (agent.ts), so `delivered` here
105
- // means "handed to the run that reports for it" — a `systemInput` that
106
- // then throws fails *that* run, and the failure reaches the replier
107
- // 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.
108
137
  if (message.kind === "reply" && isTerminal(run.state)) {
109
138
  const resumed = message.resumeRunId ? this.store.getRun(message.resumeRunId) : undefined;
110
139
  if (resumed?.startedAt)
111
- this.confirmed(message.id);
140
+ this.delivered(message);
112
141
  else if (!resumed)
113
142
  this.abandon(message, `continuation ${message.resumeRunId ?? "(none)"} is gone`);
114
143
  else if (isTerminal(resumed.state)) {
115
- // Never started, and never will: say so rather than wait for a
116
- // ceiling that would only report the same thing four minutes later.
117
- // Both ends hear it here; the supervisor may also get the
118
- // continuation's own cancelled callback, and a duplicate beats a
119
- // 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.
120
147
  this.abandon(message, `continuation ${resumed.id} ${resumed.state} before it started`);
121
148
  }
122
149
  continue;
@@ -125,15 +152,12 @@ export class TaskMessenger {
125
152
  continue;
126
153
  const target = message.toSessionId || run.targetSessionId;
127
154
  if (target)
128
- this.deliver(message, run, target);
155
+ this.deliver(message, target);
129
156
  }
130
157
  }
131
- /** Asynchronous by design: returns the receipt immediately. A decision
132
- * child states what it awaits and ends its turn; the reply arrives as a
133
- * follow-up (active run) or resumes the session (terminal run).
134
- * A decision steers the supervisor: a follow-up only lands once the
135
- * supervisor has no tool calls left, so a blocked child would wait out the
136
- * 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. */
137
161
  async contact(run, fromSessionId, reason, content) {
138
162
  if (!run.invokedBySessionId)
139
163
  throw new Error("run has no supervisor session");
@@ -142,7 +166,7 @@ export class TaskMessenger {
142
166
  }
143
167
  const message = this.create(run, reason, fromSessionId, run.invokedBySessionId, content, null);
144
168
  this.changed(message);
145
- this.deliver(message, run, run.invokedBySessionId);
169
+ this.deliver(message, run.invokedBySessionId);
146
170
  return this.require(message.id);
147
171
  }
148
172
  async reply(questionId, fromSessionId, content) {
@@ -185,7 +209,7 @@ export class TaskMessenger {
185
209
  if (continuation)
186
210
  this.startRun(continuation);
187
211
  else if (run.targetSessionId)
188
- this.deliver(reply, run, run.targetSessionId);
212
+ this.deliver(reply, run.targetSessionId);
189
213
  return this.require(reply.id);
190
214
  }
191
215
  create(run, kind, fromSessionId, toSessionId, content, replyTo) {
@@ -208,30 +232,28 @@ export class TaskMessenger {
208
232
  this.store.saveMessage(message);
209
233
  return message;
210
234
  }
211
- /** Never awaits the recipient: the seam's `systemInput` settles with the turn
212
- * the input triggers, so awaiting it would block the sender — a child's
213
- * `contact` on its supervisor's whole answer turn — which the design forbids.
214
- * So `delivered` is written by `confirmed`, against the one proof that
215
- * survives an abort or a restart: the message visible in the recipient's own
216
- * transcript. Until then it stays pending and the sweep tries again. */
217
- deliver(candidate, run, targetSessionId) {
218
- const message = this.require(candidate.id);
219
- if (message.state !== "pending" && message.state !== "failed")
220
- return;
221
- 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) {
222
238
  message.toSessionId = targetSessionId;
223
- message.state = "pending";
224
- message.error = null;
225
- this.store.saveMessage(message);
226
- this.changed(message);
227
- void this.inject(message, run, targetSessionId, this.mode(message))
228
- .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)]);
229
242
  }
230
- /** The one place a message becomes delivered. */
231
- confirmed(id) {
232
- const message = this.store.getMessage(id);
233
- if (!message || message.state !== "pending")
234
- 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) {
235
257
  message.state = "delivered";
236
258
  message.deliveredAt = Date.now();
237
259
  message.error = null;
@@ -239,37 +261,6 @@ export class TaskMessenger {
239
261
  this.store.saveMessage(message);
240
262
  this.changed(message);
241
263
  }
242
- /** Waiting is not a failed attempt: the message is in flight or the turn it
243
- * has to wait for is still running, so it is tried again shortly and the
244
- * ceiling is left for the deliveries that actually failed (the rule
245
- * outbox.ts keeps for callbacks). */
246
- defer(id) {
247
- const message = this.store.getMessage(id);
248
- if (!message || message.state !== "pending")
249
- return;
250
- message.nextAttemptAt = Date.now() + 1000;
251
- this.store.saveMessage(message);
252
- }
253
- /** Counts one hand-off and says whether it may happen: a recipient that
254
- * never records the message must not be re-sent once a second forever.
255
- * False means the ceiling was reached and the message is now expired. */
256
- spend(id) {
257
- const message = this.store.getMessage(id);
258
- if (!message || message.state !== "pending")
259
- return false;
260
- message.attempts += 1;
261
- message.nextAttemptAt = Date.now() + retryDelay(message.attempts);
262
- if (message.attempts > MAX_DELIVERY_ATTEMPTS) {
263
- this.abandon(message, undeliverable(message.attempts - 1, message.error));
264
- return false;
265
- }
266
- this.store.saveMessage(message);
267
- return true;
268
- }
269
- /** Out of attempts. Both ends are told — the recipient that was owed it and
270
- * the sender waiting on the answer — and a decision that expires here stops
271
- * suppressing its run's completion callback, which `execution.ts` decided
272
- * once, at the end of the run, and never revisits. */
273
264
  abandon(message, why) {
274
265
  message.state = "expired";
275
266
  message.error = why;
@@ -277,6 +268,11 @@ export class TaskMessenger {
277
268
  message.nextAttemptAt = null;
278
269
  this.store.saveMessage(message);
279
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) {
280
276
  this.unreachable(message.toSessionId, `a ${message.kind} from run ${message.runId}`, why);
281
277
  if (message.fromSessionId && message.fromSessionId !== message.toSessionId) {
282
278
  this.unreachable(message.fromSessionId, `your ${message.kind} on run ${message.runId}`, why);
@@ -289,69 +285,6 @@ export class TaskMessenger {
289
285
  this.store.saveRun(run);
290
286
  }
291
287
  }
292
- /** Steer whatever someone is blocked on: a decision, and the reply that
293
- * answers it — a follow-up lands only once the recipient runs out of tool
294
- * calls, so each would wait out a whole turn (the reply waited 3 minutes
295
- * behind one on 2026-09-07). Progress is a follow-up because nobody waits. */
296
- mode(message) {
297
- return message.kind === "follow_up" || message.kind === "progress" ? "follow_up" : "steer";
298
- }
299
- failed(id, error, spent) {
300
- const message = this.store.getMessage(id);
301
- // Only a still-pending message can fail: a late rejection must not undo a
302
- // delivery a newer attempt already proved, nor revive an expired one.
303
- if (message?.state !== "pending")
304
- return;
305
- // Both ends are waiting on this one: the sender for an answer, the
306
- // recipient for a message it never got told about.
307
- log.warn(`${message.kind} ${id} to session ${message.toSessionId} failed`, error);
308
- message.state = "failed";
309
- message.error = String(error);
310
- // A pass that died before the send never spent its attempt — an
311
- // unresolvable target dies there every time, and would retry forever.
312
- if (!spent)
313
- message.attempts += 1;
314
- message.nextAttemptAt = Date.now() + retryDelay(message.attempts);
315
- this.store.saveMessage(message);
316
- this.changed(message);
317
- if (message.attempts > MAX_DELIVERY_ATTEMPTS) {
318
- this.abandon(message, undeliverable(message.attempts - 1, message.error));
319
- }
320
- }
321
- /** One pass at getting the message into the recipient: the dedupe on a retry
322
- * and the proof of delivery are the same transcript read. Owns its own
323
- * failure, so an attempt is counted exactly once whether the pass died
324
- * before the send or the send itself was refused. */
325
- async inject(message, run, targetSessionId, mode) {
326
- let spent = false;
327
- try {
328
- const session = await this.router.ensure({ channelId: "task", conversationId: targetSessionId });
329
- const recorded = async () => (await session.history()).some((turn) => turn.role === "system" && turn.origin?.kind === "task-message" && turn.origin.messageId === message.id);
330
- if (await recorded())
331
- return this.confirmed(message.id);
332
- // Accepted and waiting in the recipient's queue for the running turn to
333
- // drain it: not recorded yet, and sending again would deliver the same
334
- // guidance twice. Unlike a callback this is not deferred on a busy
335
- // target — a steer's whole point is to reach the turn already running —
336
- // so the queue is where it sits, and waiting for it costs no attempt.
337
- if (await this.queued(session, message.id))
338
- return this.defer(message.id);
339
- spent = this.spend(message.id);
340
- if (!spent)
341
- return;
342
- await session.systemInput(this.format(message, run), this.origin(message, run), mode === "follow_up" ? "followUp" : mode);
343
- if (await recorded())
344
- this.confirmed(message.id);
345
- }
346
- catch (error) {
347
- this.failed(message.id, error, spent);
348
- }
349
- }
350
- /** In the recipient's queue, by the id its origin carries — the transcript
351
- * read above answers "landed", this answers "handed over and waiting". */
352
- async queued(session, id) {
353
- return (await session.pendingSystemInputs()).some((origin) => origin.kind === "task-message" && origin.messageId === id);
354
- }
355
288
  format(message, run) {
356
289
  const title = run.context.definition.name;
357
290
  if (message.kind === "progress") {
@@ -1,36 +1,28 @@
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.
1
+ // One delivery engine for everything that reaches a session as a system input:
2
+ // run callbacks, group callbacks and run messages.
6
3
  import { logger } from "../log.js";
7
4
  import { MAX_DELIVERY_ATTEMPTS, retryDelay, undeliverable } from "./types.js";
8
5
  const log = logger("tasks");
9
6
  /** The records a delivery names, from either side of the seam: a batch names
10
- * every one of them, a group names itself. */
11
- const callbackIds = (origin) => origin?.kind === "task-callback" ? origin.runIds ?? [origin.runId] : [];
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
+ };
12
15
  export class Outbox {
13
16
  router;
14
17
  kind;
15
- unreachable;
16
18
  delivering = new Set();
17
- constructor(router, kind,
18
- /** Reports a delivery nobody can complete (service.ts owns the surfaces). */
19
- unreachable) {
19
+ constructor(router, kind) {
20
20
  this.router = router;
21
21
  this.kind = kind;
22
- this.unreachable = unreachable;
23
22
  }
24
- /**
25
- * Delivers a batch aimed at one session: one model turn drains the backlog
26
- * instead of one turn per record.
27
- *
28
- * `delivered` is written only against proof — the input visible in the
29
- * recipient's own transcript. Pi's queues are memory, so an abort or a
30
- * restart drops an accepted input and `systemInput` resolving proves
31
- * nothing; a delivery reported on a resolved send is how a delegating agent
32
- * ends up waiting forever on a result that was never read.
33
- */
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. */
34
26
  async deliver(sessionId, batch) {
35
27
  const mine = batch.filter((record) => !this.delivering.has(this.kind.id(record)));
36
28
  if (mine.length === 0)
@@ -47,22 +39,16 @@ export class Outbox {
47
39
  const live = unproven.filter((record) => !this.spent(record, sessionId));
48
40
  if (live.length === 0)
49
41
  return;
50
- // Waiting for a busy target is not a delivery attempt: counting it would
51
- // inflate the attempts once per second and skip the failure backoff
52
- // straight to its ceiling. A record delegated with `steer` is the
53
- // exception it asked for — it joins the running turn instead, and the
54
- // rest of the batch keeps waiting for the turn to end. But only once:
55
- // handed over, a steer sits in Pi's in-memory queue, invisible in the
56
- // transcript until the turn drains it, so the queue is the second place
57
- // this has to look before deciding nothing arrived (messages.ts:340).
58
- const streaming = session.state === "streaming";
59
42
  let sending = live;
60
- if (streaming) {
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.
46
+ if (session.state === "streaming") {
61
47
  const handedOver = await this.queued(session);
62
- const steerNow = (record) => record.callbackMode === "steer" && !handedOver.has(this.kind.id(record));
63
- sending = live.filter(steerNow);
48
+ const sendNow = (record) => (record.callbackMode === "steer" || this.kind.queues === true) && !handedOver.has(this.kind.id(record));
49
+ sending = live.filter(sendNow);
64
50
  for (const record of live)
65
- if (!steerNow(record))
51
+ if (!sendNow(record))
66
52
  this.defer(record);
67
53
  if (sending.length === 0)
68
54
  return;
@@ -72,14 +58,12 @@ export class Outbox {
72
58
  counted.add(this.kind.id(record));
73
59
  }
74
60
  const { text, origin } = this.kind.input(sending);
61
+ const mode = sending.every((record) => record.callbackMode === "steer") ? "steer" : "followUp";
75
62
  log.debug(`callback for ${sending.map((r) => this.kind.id(r)).join(", ")} → session ${sessionId}`);
76
- // Not awaited: `systemInput` settles with the recipient's whole turn, and
77
- // holding the delivery lock that long would keep the proof from ever
78
- // being read — which is the only thing that marks this delivered.
79
- session.systemInput(text, origin, streaming ? "steer" : "followUp")
63
+ // Not awaited: `systemInput` settles with the recipient's whole turn.
64
+ session.systemInput(text, origin, mode)
80
65
  .catch((error) => this.retry(sessionId, sending, error, counted));
81
- // Pi records the input as it starts the turn, so the proof is usually
82
- // here already; the tick sweep is the backstop when it is not.
66
+ // Pi records the input as it starts the turn; the tick sweep is the backstop.
83
67
  await this.settle(sending, session);
84
68
  }
85
69
  catch (error) {
@@ -90,13 +74,11 @@ export class Outbox {
90
74
  this.delivering.delete(this.kind.id(record));
91
75
  }
92
76
  }
93
- /** Handed over and waiting in the recipient's queue for the running turn to
94
- * drain it — what the transcript cannot answer yet. Empty unless the
95
- * session is streaming: Pi drops the list when the turn ends. */
77
+ /** What the transcript cannot answer yet; empty unless the session is streaming. */
96
78
  async queued(session) {
97
79
  const ids = new Set();
98
80
  for (const origin of await session.pendingSystemInputs())
99
- for (const id of callbackIds(origin))
81
+ for (const id of recordIds(origin))
100
82
  ids.add(id);
101
83
  return ids;
102
84
  }
@@ -106,7 +88,7 @@ export class Outbox {
106
88
  for (const turn of await session.history()) {
107
89
  if (turn.role !== "system")
108
90
  continue;
109
- for (const id of callbackIds(turn.origin))
91
+ for (const id of recordIds(turn.origin))
110
92
  seen.add(id);
111
93
  }
112
94
  const unproven = [];
@@ -119,9 +101,7 @@ export class Outbox {
119
101
  }
120
102
  return unproven;
121
103
  }
122
- /** The recipient is waiting for an answer that is now late: the retry itself
123
- * is silent, so this line is the only sign it is being retried — and the
124
- * ceiling is what ends the retrying out loud. */
104
+ /** The retry itself is silent, so this line is the only sign of it. */
125
105
  retry(sessionId, batch, error, counted) {
126
106
  log.warn(`callback to session ${sessionId} failed, will retry`, error);
127
107
  for (const stale of batch) {
@@ -134,8 +114,7 @@ export class Outbox {
134
114
  this.spent(record, sessionId);
135
115
  }
136
116
  }
137
- /** Out of attempts: stop, record why, and report it. An agent waiting on a
138
- * 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. */
139
118
  spent(record, sessionId) {
140
119
  if (record.callbackAttempts < MAX_DELIVERY_ATTEMPTS)
141
120
  return false;
@@ -144,7 +123,7 @@ export class Outbox {
144
123
  record.callbackError = undeliverable(record.callbackAttempts, record.callbackError);
145
124
  record.callbackNextAttemptAt = null;
146
125
  this.write(record);
147
- this.unreachable(sessionId, this.kind.describe(record), record.callbackError);
126
+ this.kind.abandoned(record, sessionId, record.callbackError);
148
127
  }
149
128
  return true;
150
129
  }
@@ -153,8 +132,8 @@ export class Outbox {
153
132
  record.callbackNextAttemptAt = Date.now() + 1000;
154
133
  this.kind.save(record);
155
134
  }
156
- /** Handed over, proof pending. Backs off like a failure, so an input the
157
- * 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. */
158
137
  sent(record) {
159
138
  record.callbackAttempts += 1;
160
139
  record.callbackState = "pending";
@@ -1,7 +1,5 @@
1
- // The area's HTTP surface: tasks, runs, group and message routes for the
2
- // Console, plus the Activity snapshot it draws its graph from. A route reads
3
- // its body, names the caller and hands the decision to TaskService — policy
4
- // that lives here would be policy the task tool does not get.
1
+ // The area's HTTP surface. A route names the caller and hands the decision to
2
+ // TaskService: policy here would be policy the task tool does not get.
5
3
  import { record, requiredString } from "./definitions.js";
6
4
  /** Reject malformed filters instead of silently widening a global query. */
7
5
  function runQuery(params) {
@@ -224,9 +222,8 @@ export function registerTaskRoutes(app, tasks, activity) {
224
222
  const body = record(await jsonBody(c.req));
225
223
  const input = body && "input" in body ? body.input : null;
226
224
  try {
227
- // A caller that names a mode gets an answer about it: dropping an
228
- // unsupported override would run the definition's own policy instead —
229
- // on a reuse definition, work injected into a live session.
225
+ // Dropping an unsupported override would run the definition's own policy
226
+ // instead — on a reuse definition, work injected into a live session.
230
227
  if (body?.sessionMode !== undefined && body.sessionMode !== "fresh") {
231
228
  throw new Error(`unsupported sessionMode: ${String(body.sessionMode)}`);
232
229
  }
@@ -1,8 +1,5 @@
1
- // A definition plus an input becomes a queued run: where it came from, how
2
- // deep in a subagent chain it sits, whether it overlaps a run already going,
3
- // and which session hears about it. The limits that keep a chain from
4
- // exploding (depth, children per root) are decided here, once, because every
5
- // caller — scheduler, tool, HTTP — enqueues through this one door.
1
+ // A definition plus an input becomes a queued run. The depth and per-root
2
+ // limits are decided here because every caller enqueues through this one door.
6
3
  import { logger } from "../log.js";
7
4
  import { newId } from "./definitions.js";
8
5
  const log = logger("tasks");
@@ -92,10 +89,8 @@ export class TaskRunQueue {
92
89
  const { id, depth, triggerSource: source } = run;
93
90
  const overlapped = run.skipReason === "overlap";
94
91
  const definition = run.context.definition;
95
- // Why a run exists is the first question asked of a surprising one, and it
96
- // is answerable only here: the row keeps the ids, not the reason. A watch
97
- // probe queues on every interval and mostly matches nothing, so it says so
98
- // at debug and lets its settled line (execution.ts) carry the news.
92
+ // Why a run exists is answerable only here: the row keeps the ids, not the
93
+ // reason. A watch probe queues every interval, so it logs at debug.
99
94
  const queued = `run ${id} ${run.state}: ${definition.name} via ${source}` +
100
95
  `${overlapped ? " (overlapped)" : ""}${depth > 0 ? ` depth ${String(depth)}` : ""}`;
101
96
  if (source === "watch" && !overlapped)