@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,15 @@
1
- // A run that *is* a Pi session: which session it opens (fresh or reused),
2
- // what the child is told before the prompt, and how many may run at once. The
3
- // concurrency caps are here rather than in execution.ts because they bound
4
- // agents specifically — a bash run costs a process, an agent run costs a
5
- // model's context and someone's rate limit.
1
+ // A run that *is* a Pi session: which session it opens, what the child is told
2
+ // before the prompt, and how many may run at once (an agent run costs a
3
+ // model's context and someone's rate limit, so the caps are here).
6
4
  import { quietLabel, splitReply } from "../core/reply.js";
7
- import { Router } from "../core/router.js";
5
+ import { logger } from "../log.js";
8
6
  import { runSource } from "./callbacks.js";
9
- import { TaskMessenger } from "./messages.js";
10
- import { TaskStore } from "./store.js";
11
- const MAX_ACTIVE_AGENTS = 4;
12
- /** What a child cannot know unless told. Every session gets the chat-surface
13
- * contract (<pier>/AGENTS.md), task runs included — so the delegation prompt
14
- * says which of it does not apply here, and a supervised run how to reach the
15
- * agent that is waiting on it. Skipped on resume: the session already saw it. */
7
+ // Agent runs are I/O-bound: the cap is there for API pressure and runaway
8
+ // fan-out, not for this machine's CPU.
9
+ const MAX_ACTIVE_AGENTS = 6;
10
+ const log = logger("tasks");
11
+ /** Every session gets the chat-surface contract, task runs included, so the
12
+ * delegation prompt says which of it does not apply. Skipped on resume. */
16
13
  const preamble = (run) => {
17
14
  // A cron/watch task with a session callback is read by an agent too.
18
15
  const audience = run.invokedBySessionId
@@ -44,40 +41,37 @@ export class AgentTaskRunner {
44
41
  this.changed = changed;
45
42
  }
46
43
  async execute(run, action, signal, start) {
47
- await this.acquireSlot(run, signal);
48
- try {
49
- const session = await this.resolveSession(run, action);
50
- return await this.withSession(session.id, async () => {
44
+ // Reserve reuse order before resolving the session: a slow resume must not
45
+ // let a later run pass it. Fresh runs each have their own queue key.
46
+ return this.withSession(run.targetSessionId ?? run.id, signal, async () => {
47
+ const reused = run.targetSessionId ? await this.resolveSession(run, action, signal) : undefined;
48
+ if (reused)
49
+ await this.waitUntilIdle(reused, signal);
50
+ await this.acquireSlot(run, signal);
51
+ try {
52
+ const session = reused ?? await this.resolveSession(run, action, signal);
53
+ // A reused session may have become busy while we waited for a slot.
51
54
  await this.waitUntilIdle(session, signal);
52
- // Task requests come seconds apart — the 1h Anthropic cache-write premium
53
- // never earns its 2× back, so task runs use the 5m TTL. Set here, not in
54
- // resolveSession: this covers create, reuse and resume alike, on
55
- // every attempt — and only after the session is idle, so a reused
56
- // interactive session's in-flight turn keeps its 1h writes.
55
+ signal.throwIfAborted();
56
+ // Task requests come seconds apart, so the 1h cache-write premium never
57
+ // earns back; after idle, so a reused session's in-flight turn keeps its 1h.
57
58
  session.setCacheRetention("short");
58
59
  start();
59
- // No input is no block: `<task_input>\nnull\n</task_input>` is four
60
- // lines telling the agent nothing, on every run that has no input.
61
- // Compact for the same reason tool results are (agent/pi.ts), and
62
- // `<\/` is the same JSON — a value cannot close the fence early.
60
+ // No input is no block. `<\/` is the same JSON, so a value cannot close
61
+ // the fence early.
63
62
  const input = run.input === undefined || run.input === null
64
63
  ? ""
65
64
  : `\n\n<task_input>\n${JSON.stringify(run.input).replaceAll("</task_input>", "<\\/task_input>")}\n</task_input>`;
66
65
  const prompt = run.context.resumePrompt ?? `${preamble(run)}${action.prompt}${input}`;
67
66
  run.context.sessionId = session.id;
68
67
  run.context.model = session.model;
69
- // The level the session settled on, not the one the task asked for:
70
- // an unspecified effort inherits the caller's, and the card in the
71
- // subagent's own transcript should say which one that was.
68
+ // The level the session settled on: an unspecified effort inherits the caller's.
72
69
  run.context.thinking = session.thinkingLevel;
73
70
  run.context.renderedPrompt = prompt;
74
71
  this.store.saveRun(run);
75
72
  let text = "";
76
- // Not only what the turn said but how it ended: a provider outage ends
77
- // it with an empty reply, and a run that settles on that reports "no
78
- // reply" — the one wording for a turn that *chose* to say nothing, so
79
- // the outage arrives at the caller looking like an answer (§5b). Pi has
80
- // already retried by the time this lands; the run only reports.
73
+ // How it ended, too: a provider outage ends with an empty reply, which
74
+ // would otherwise report as a turn that chose to say nothing (§5).
81
75
  let failure;
82
76
  const unsubscribe = session.subscribe((event) => {
83
77
  if (event.type === "turn-end") {
@@ -85,22 +79,16 @@ export class AgentTaskRunner {
85
79
  failure = event.error;
86
80
  }
87
81
  });
88
- // The race below also settles this attempt if Pi ignores the abort:
89
- // a hung turn must not hold one of the 4 slots (and its waiters)
90
- // forever — the same guard execution.ts gives task-type children.
91
- let rejectAborted = () => { };
92
- const abortedTurn = new Promise((_, reject) => { rejectAborted = reject; });
93
- abortedTurn.catch(() => { }); // handled via the race; never unhandled
82
+ // Cancel the SDK turn, but release our wait even if its abort hangs.
94
83
  const abort = () => {
95
- void session.abort();
96
- rejectAborted(new Error("cancelled"));
84
+ void Promise.resolve().then(() => session.abort())
85
+ .catch((error) => log.warn(`run ${run.id} session abort failed`, error));
97
86
  };
98
87
  signal.addEventListener("abort", abort, { once: true });
99
- // A pre-aborted signal never fires the listener: check before the
100
- // prompt starts or a cancelled run would hang until its timeout.
101
- if (signal.aborted)
102
- throw new Error("cancelled");
103
88
  try {
89
+ // Pre-abort does not fire a newly registered listener; keep the check
90
+ // inside finally's scope so it also restores retention/subscriptions.
91
+ signal.throwIfAborted();
104
92
  const turn = session.systemInput(prompt, {
105
93
  kind: "task-delegation",
106
94
  taskId: run.taskId,
@@ -110,54 +98,49 @@ export class AgentTaskRunner {
110
98
  }, "prompt");
111
99
  await Promise.resolve();
112
100
  this.messages.deliverPendingControls(run);
113
- await Promise.race([turn, abortedTurn]);
101
+ await this.untilAborted(turn, signal);
114
102
  if (signal.aborted)
115
103
  throw new Error("cancelled");
116
- // Before the fallback below: on a reused session that reads the
117
- // *previous* turn's answer back out of history and reports it as
118
- // this run's result.
104
+ // Before the fallback: on a reused session it would read the previous
105
+ // turn's answer back as this run's result.
119
106
  if (failure)
120
107
  throw new Error(failure);
121
108
  if (!text) {
122
- const history = await session.history();
109
+ const history = await this.untilAborted(session.history(), signal);
123
110
  text = [...history].reverse().find((turn) => turn.role === "assistant")?.text ?? "";
124
111
  }
125
- // The chat contract is injected into task sessions too, so a child's
126
- // reply may carry chat-only markup. The result is read by a
127
- // supervisor or the Console, never a chat renderer: buttons are
128
- // dropped, and a turn that said nothing names which kind of nothing
129
- // it was (principle 5b) instead of storing an empty result.
112
+ // The result is read by a supervisor, never a chat renderer: buttons
113
+ // are dropped, and an empty turn names which kind of nothing (§5).
130
114
  const reply = splitReply(text);
131
115
  return { type: "agent", text: reply.text || quietLabel(reply.silence), sessionId: session.id };
132
116
  }
133
117
  finally {
134
- // The run is what earns "short"; a reused interactive session goes
135
- // back to chat afterwards. For task-created sessions this is moot —
136
- // idle until the next run downgrades them again.
118
+ // A reused interactive session goes back to chat afterwards.
137
119
  session.setCacheRetention("long");
138
120
  signal.removeEventListener("abort", abort);
139
121
  unsubscribe();
140
122
  }
141
- });
142
- }
143
- finally {
144
- this.releaseSlot(run.id);
145
- }
123
+ }
124
+ finally {
125
+ this.releaseSlot(run.id);
126
+ }
127
+ });
146
128
  }
147
- async resolveSession(run, action) {
129
+ async resolveSession(run, action, signal) {
130
+ signal.throwIfAborted();
148
131
  if (run.targetSessionId) {
149
- return this.router.ensure({ channelId: "task", conversationId: run.targetSessionId });
132
+ return this.untilAborted(this.router.ensure({ channelId: "task", conversationId: run.targetSessionId }), signal);
150
133
  }
151
134
  const policy = action.session;
152
- // Definitions stored before fork was removed still say `"fork"`. Refused by
153
- // name: every directory this could pick instead is a guess at what the
154
- // author meant, and a child in the wrong tree edits real files.
135
+ // Stored definitions may still say `"fork"`. Refused by name: any directory
136
+ // picked instead is a guess, and a child in the wrong tree edits real files.
155
137
  if (run.sessionMode === "fork" || policy.mode === "fork") {
156
138
  throw new Error(`task "${run.context.definition.name}" uses the removed fork session mode; recreate it with {"mode":"fresh","cwd":"/abs/path"}`);
157
139
  }
158
140
  const cwd = policy.mode === "fresh"
159
141
  ? policy.cwd
160
- : (await this.factory.find(policy.sessionId))?.cwd ?? "";
142
+ : (await this.untilAborted(this.factory.find(policy.sessionId), signal))?.cwd ?? "";
143
+ signal.throwIfAborted();
161
144
  if (!cwd)
162
145
  throw new Error("could not resolve child working directory");
163
146
  const opts = {
@@ -169,33 +152,50 @@ export class AgentTaskRunner {
169
152
  (run.sourceSessionId ? this.router.modelOf(run.sourceSessionId) : undefined),
170
153
  thinking: action.launch?.thinking,
171
154
  };
172
- const session = await this.factory.create(opts);
173
- run.targetSessionId = session.id;
174
- run.context.sessionId = session.id;
175
- run.context.cwd = cwd;
176
- this.store.saveRun(run);
177
- this.router.attach({ channelId: "task", conversationId: session.id }, session);
178
- this.changed(run);
155
+ const opening = this.factory.create(opts).then(async (session) => {
156
+ // SDK creation cannot be cancelled; a late session still belongs to this
157
+ // run. Cancellation's final save can race this callback, so both records get it.
158
+ run.targetSessionId = session.id;
159
+ run.context.sessionId = session.id;
160
+ run.context.cwd = cwd;
161
+ const owner = signal.aborted ? this.store.getRun(run.id) ?? run : run;
162
+ owner.targetSessionId = run.targetSessionId;
163
+ owner.context = run.context;
164
+ try {
165
+ this.store.saveRun(owner);
166
+ this.changed(owner);
167
+ }
168
+ finally {
169
+ if (signal.aborted)
170
+ await session.dispose();
171
+ else
172
+ this.router.attach({ channelId: "task", conversationId: session.id }, session);
173
+ }
174
+ return session;
175
+ });
176
+ // A late rejection cannot reach execute's already-cancelled race.
177
+ void opening.catch((error) => {
178
+ if (signal.aborted)
179
+ log.warn(`session creation for cancelled run ${run.id} failed`, error);
180
+ });
181
+ const session = await this.untilAborted(opening, signal);
182
+ signal.throwIfAborted();
179
183
  return session;
180
184
  }
181
185
  async acquireSlot(run, signal) {
182
186
  while (this.active.size >= MAX_ACTIVE_AGENTS) {
183
- if (signal.aborted)
184
- throw new Error("cancelled");
185
- await new Promise((resolve, reject) => {
186
- const wake = () => {
187
- signal.removeEventListener("abort", abort);
188
- this.slotWaiters.delete(wake);
189
- resolve();
190
- };
191
- const abort = () => {
192
- this.slotWaiters.delete(wake);
193
- reject(new Error("cancelled"));
194
- };
195
- this.slotWaiters.add(wake);
196
- signal.addEventListener("abort", abort, { once: true });
197
- });
187
+ let wake = () => { };
188
+ try {
189
+ await this.untilAborted(new Promise((resolve) => {
190
+ wake = resolve;
191
+ this.slotWaiters.add(wake);
192
+ }), signal);
193
+ }
194
+ finally {
195
+ this.slotWaiters.delete(wake);
196
+ }
198
197
  }
198
+ signal.throwIfAborted();
199
199
  this.active.add(run.id);
200
200
  }
201
201
  releaseSlot(id) {
@@ -203,43 +203,58 @@ export class AgentTaskRunner {
203
203
  for (const wake of this.slotWaiters)
204
204
  wake();
205
205
  }
206
- async withSession(sessionId, fn) {
206
+ async untilAborted(promise, signal) {
207
+ let abort = () => { };
208
+ const cancelled = new Promise((_, reject) => {
209
+ abort = () => reject(new Error("cancelled"));
210
+ signal.addEventListener("abort", abort, { once: true });
211
+ if (signal.aborted)
212
+ abort();
213
+ });
214
+ try {
215
+ return await Promise.race([promise, cancelled]);
216
+ }
217
+ finally {
218
+ signal.removeEventListener("abort", abort);
219
+ }
220
+ }
221
+ async withSession(sessionId, signal, fn) {
222
+ signal.throwIfAborted();
207
223
  const previous = this.sessionTails.get(sessionId) ?? Promise.resolve();
208
224
  let release = () => { };
209
225
  const gate = new Promise((resolve) => { release = resolve; });
210
226
  const tail = previous.then(() => gate);
211
227
  this.sessionTails.set(sessionId, tail);
212
- await previous;
228
+ // Even if this waiter is cancelled, its tail still includes its predecessor.
229
+ // Only delete after that tail drains, or a new arrival could skip the queue.
230
+ void tail.then(() => {
231
+ if (this.sessionTails.get(sessionId) === tail)
232
+ this.sessionTails.delete(sessionId);
233
+ });
213
234
  try {
235
+ await this.untilAborted(previous, signal);
236
+ signal.throwIfAborted();
214
237
  return await fn();
215
238
  }
216
239
  finally {
217
240
  release();
218
- if (this.sessionTails.get(sessionId) === tail)
219
- this.sessionTails.delete(sessionId);
220
241
  }
221
242
  }
222
243
  async waitUntilIdle(session, signal) {
223
244
  // The session's own event stream is the only busy/idle signal — no polling.
224
245
  while (session.state === "streaming") {
225
- if (signal.aborted)
226
- throw new Error("cancelled");
227
- await new Promise((resolve, reject) => {
228
- const settle = (error) => {
229
- unsubscribe();
230
- signal.removeEventListener("abort", onAbort);
231
- if (error)
232
- reject(error);
233
- else
234
- resolve();
235
- };
236
- const onAbort = () => settle(new Error("cancelled"));
237
- const unsubscribe = session.subscribe((event) => {
238
- if (event.type === "state" && event.state === "idle")
239
- settle();
240
- });
241
- signal.addEventListener("abort", onAbort, { once: true });
242
- });
246
+ let unsubscribe = () => { };
247
+ try {
248
+ await this.untilAborted(new Promise((resolve) => {
249
+ unsubscribe = session.subscribe((event) => {
250
+ if (event.type === "state" && event.state === "idle")
251
+ resolve();
252
+ });
253
+ }), signal);
254
+ }
255
+ finally {
256
+ unsubscribe();
257
+ }
243
258
  }
244
259
  }
245
260
  }
@@ -1,17 +1,10 @@
1
1
  // What a finished run says to the session that delegated it. Delivery itself
2
2
  // belongs to outbox.ts; this file owns the run vocabulary and the batching.
3
- import { Router } from "../core/router.js";
4
3
  import { Outbox } from "./outbox.js";
5
- import { TaskStore } from "./store.js";
6
- /** How a run is named to the session that gets its result: the run id, and the
7
- * session that did the work — a relayer's next move is a deep link to it
8
- * (`#/session/<id>`), and without this line that costs a second tool call.
9
- * Shared with the group callback, which has always carried both. */
4
+ /** The run id and the session that did the work: a relayer's next move is a
5
+ * deep link to it, and without this that costs a second tool call. */
10
6
  export const runRef = (run) => `Run: ${run.id}${run.targetSessionId ? ` / Session: ${run.targetSessionId}` : ""}`;
11
- /** What the card in the recipient's transcript says the input came from. Part
12
- * of the run vocabulary for the same reason `runRef` is: the callback, the
13
- * subagent messages and the delegation prompt all name a run, and three
14
- * spellings of "which task, on what model" would be three cards. */
7
+ /** What the card in the recipient's transcript says the input came from. */
15
8
  export const runSource = (run) => ({
16
9
  taskName: run.context.definition.name,
17
10
  ...(run.context.model ? { model: run.context.model } : {}),
@@ -28,7 +21,7 @@ export function runResultText(run) {
28
21
  if (run.result?.type === "watch")
29
22
  result = "Watch condition did not match";
30
23
  if (result.length > 8000)
31
- result = `${result.slice(0, 8000)}\n[truncated — task tool get run_id ${run.id} returns the full text]`;
24
+ result = `${result.slice(0, 8000)}\n[truncated — task tool recover run_id ${run.id} with a reason returns the full text]`;
32
25
  return result;
33
26
  }
34
27
  export class TaskCallbacks {
@@ -49,15 +42,13 @@ export class TaskCallbacks {
49
42
  runId: runs[0].id,
50
43
  sourceSessionId: runs[0].targetSessionId,
51
44
  runIds: runs.map((run) => run.id),
52
- // Only when the input is about one run: a batch is several tasks in
53
- // one delivery, and the first one's name and model as the card's
54
- // caption would attribute every other result to it. The text names
55
- // each run; the card says nothing rather than something false.
45
+ // Only for one run: a batch's caption would attribute every result
46
+ // to the first run's name and model.
56
47
  ...(runs.length === 1 ? { source: runSource(runs[0]), state: runs[0].state } : {}),
57
48
  },
58
49
  }),
59
- describe: (run) => `the result of "${run.context.definition.name}"`,
60
- }, unreachable);
50
+ abandoned: (run, sessionId, why) => unreachable(sessionId, `the result of "${run.context.definition.name}"`, why),
51
+ });
61
52
  }
62
53
  target(callback, origin) {
63
54
  if (callback.type === "session")
@@ -77,8 +68,7 @@ export class TaskCallbacks {
77
68
  if (!first?.callbackSessionId || (first.callbackState !== "pending" && first.callbackState !== "failed"))
78
69
  return;
79
70
  const sessionId = first.callbackSessionId;
80
- // Retry due-times are ignored when sweeping the batch: once one callback is
81
- // deliverable, everything pending for the session rides along.
71
+ // Once one callback is deliverable, everything pending for the session rides along.
82
72
  const batch = this.store.listPendingCallbacks(Number.MAX_SAFE_INTEGER)
83
73
  .filter((run) => run.callbackSessionId === sessionId);
84
74
  if (!batch.some((run) => run.id === first.id))
@@ -5,6 +5,7 @@ import { spawn } from "node:child_process";
5
5
  import { logger } from "../log.js";
6
6
  const log = logger("tasks");
7
7
  const OUTPUT_LIMIT = 1024 * 1024;
8
+ const KILL_GRACE_MS = 250;
8
9
  class CappedOutput {
9
10
  chunks = [];
10
11
  bytes = 0;
@@ -38,34 +39,52 @@ export function runBash(script, cwd, input, signal) {
38
39
  const stdout = new CappedOutput();
39
40
  const stderr = new CappedOutput();
40
41
  let settled = false;
41
- const kill = () => {
42
+ let killTimer;
43
+ const kill = (signal) => {
42
44
  if (!child.pid)
43
45
  return;
44
46
  try {
45
47
  if (process.platform === "win32")
46
- child.kill("SIGTERM");
48
+ child.kill(signal);
47
49
  else
48
- process.kill(-child.pid, "SIGTERM");
50
+ process.kill(-child.pid, signal);
49
51
  }
50
- catch {
51
- // The process may have exited between the state check and kill.
52
+ catch (err) {
53
+ // ESRCH means the owned process group already exited.
54
+ if (err.code !== "ESRCH")
55
+ log.warn(`run ${signal} failed: ${String(err)}`);
52
56
  }
53
57
  };
54
- signal.addEventListener("abort", kill, { once: true });
58
+ const abort = () => {
59
+ kill("SIGTERM");
60
+ killTimer = setTimeout(() => kill("SIGKILL"), KILL_GRACE_MS);
61
+ killTimer.unref();
62
+ };
63
+ const cleanup = () => {
64
+ clearTimeout(killTimer);
65
+ signal.removeEventListener("abort", abort);
66
+ };
67
+ signal.addEventListener("abort", abort, { once: true });
68
+ if (signal.aborted)
69
+ abort();
55
70
  child.stdout.on("data", (chunk) => stdout.add(chunk));
56
71
  child.stderr.on("data", (chunk) => stderr.add(chunk));
57
72
  child.on("error", (err) => {
58
73
  if (settled)
59
74
  return;
60
75
  settled = true;
61
- signal.removeEventListener("abort", kill);
76
+ cleanup();
62
77
  reject(err);
63
78
  });
64
79
  child.on("close", (code) => {
65
80
  if (settled)
66
81
  return;
67
82
  settled = true;
68
- signal.removeEventListener("abort", kill);
83
+ // A TERM trap may exit the shell while a descendant ignores TERM and
84
+ // has redirected its pipes. Closing the shell must not spare that group.
85
+ if (signal.aborted)
86
+ kill("SIGKILL");
87
+ cleanup();
69
88
  resolve({
70
89
  exitCode: code,
71
90
  stdout: stdout.text(),
@@ -74,12 +93,8 @@ export function runBash(script, cwd, input, signal) {
74
93
  stderrTruncated: stderr.truncated,
75
94
  });
76
95
  });
77
- // A script that never reads stdin is ordinary (`exit 0`, a one-line curl),
78
- // and writing the input to a pipe nobody is holding raises EPIPE *here*.
79
- // Unhandled, that is an `error` event on a stream, which is an uncaught
80
- // exception, which is main.ts exiting the process: one task script could
81
- // take every session and every other run down with it. The input not being
82
- // wanted is not a failure of the run — anything else still gets said.
96
+ // A script that never reads stdin raises EPIPE here; unhandled, that is an
97
+ // uncaught exception that exits the process. Not wanting the input is not a failure.
83
98
  child.stdin.on("error", (err) => {
84
99
  if (err.code !== "EPIPE")
85
100
  log.warn(`run input could not be written: ${err.message}`);