@timqi/pier 0.0.28 → 0.1.0

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 (95) hide show
  1. package/dist/agent/config-sync.js +12 -1
  2. package/dist/agent/config.js +53 -23
  3. package/dist/agent/events.js +27 -4
  4. package/dist/agent/listing.js +114 -16
  5. package/dist/agent/pi.js +158 -16
  6. package/dist/channels/lark-panel.js +1 -1
  7. package/dist/core/inbox.js +1 -1
  8. package/dist/core/router.js +14 -0
  9. package/dist/core/types.js +13 -1
  10. package/dist/db.js +48 -0
  11. package/dist/drain.js +1 -1
  12. package/dist/extensions/web/content.js +2 -2
  13. package/dist/main.js +8 -1
  14. package/dist/secrets.js +1 -1
  15. package/dist/service.js +2 -2
  16. package/dist/settings.js +31 -10
  17. package/dist/tasks/agent.js +109 -73
  18. package/dist/tasks/callbacks.js +1 -3
  19. package/dist/tasks/command.js +27 -8
  20. package/dist/tasks/execution.js +6 -4
  21. package/dist/tasks/groups.js +35 -26
  22. package/dist/tasks/messages.js +33 -27
  23. package/dist/tasks/outbox.js +48 -21
  24. package/dist/tasks/routes.js +1 -4
  25. package/dist/tasks/runs.js +10 -4
  26. package/dist/tasks/service.js +24 -12
  27. package/dist/tasks/store.js +17 -3
  28. package/dist/tasks/tool.js +97 -25
  29. package/dist/tools.js +2 -2
  30. package/dist/update.js +1 -1
  31. package/dist/web/instance.js +10 -3
  32. package/dist/web/providers.js +9 -2
  33. package/dist/web/public/assets/{activity-DVamP8fs.js → activity-Bl3vZukb.js} +2 -2
  34. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  35. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  36. package/dist/web/public/assets/boards-DYuf4Mlj.js +1 -0
  37. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  38. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  39. package/dist/web/public/assets/explorer-qJH_9nTE.js +4 -0
  40. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  41. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  42. package/dist/web/public/assets/index-Dqdb-Eqt.js +85 -0
  43. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  44. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  45. package/dist/web/public/assets/index-DzmMzvi_.css +2 -0
  46. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  47. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  48. package/dist/web/public/assets/runs-BLJu7EXN.js +1 -0
  49. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  50. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  51. package/dist/web/public/assets/settings-BrdVh-Zi.js +5 -0
  52. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  53. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  54. package/dist/web/public/assets/task-runs-CeQS1rxa.js +3 -0
  55. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  56. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  57. package/dist/web/public/assets/tasks-bcb3fYdK.js +4 -0
  58. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  59. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
  60. package/dist/web/public/index.html +103 -134
  61. package/dist/web/public/index.html.br +0 -0
  62. package/dist/web/public/index.html.gz +0 -0
  63. package/dist/web/public/manifest.webmanifest +2 -2
  64. package/dist/web/public/manifest.webmanifest.br +0 -0
  65. package/dist/web/public/manifest.webmanifest.gz +0 -0
  66. package/dist/web/server.js +55 -36
  67. package/dist/web/session-state.js +54 -33
  68. package/package.json +2 -1
  69. package/skills/pier-tasks/SKILL.md +146 -127
  70. package/dist/web/public/assets/activity-DVamP8fs.js.br +0 -0
  71. package/dist/web/public/assets/activity-DVamP8fs.js.gz +0 -0
  72. package/dist/web/public/assets/boards-BKtjPwus.js +0 -1
  73. package/dist/web/public/assets/boards-BKtjPwus.js.br +0 -0
  74. package/dist/web/public/assets/boards-BKtjPwus.js.gz +0 -0
  75. package/dist/web/public/assets/explorer-CjO9NAKQ.js +0 -4
  76. package/dist/web/public/assets/explorer-CjO9NAKQ.js.br +0 -0
  77. package/dist/web/public/assets/explorer-CjO9NAKQ.js.gz +0 -0
  78. package/dist/web/public/assets/index-C23sXyS3.css +0 -2
  79. package/dist/web/public/assets/index-C23sXyS3.css.br +0 -0
  80. package/dist/web/public/assets/index-C23sXyS3.css.gz +0 -0
  81. package/dist/web/public/assets/index-DzWnvhXb.js +0 -85
  82. package/dist/web/public/assets/index-DzWnvhXb.js.br +0 -0
  83. package/dist/web/public/assets/index-DzWnvhXb.js.gz +0 -0
  84. package/dist/web/public/assets/runs-CxIZAx1s.js +0 -1
  85. package/dist/web/public/assets/runs-CxIZAx1s.js.br +0 -0
  86. package/dist/web/public/assets/runs-CxIZAx1s.js.gz +0 -0
  87. package/dist/web/public/assets/settings-B8JWLbpL.js +0 -5
  88. package/dist/web/public/assets/settings-B8JWLbpL.js.br +0 -0
  89. package/dist/web/public/assets/settings-B8JWLbpL.js.gz +0 -0
  90. package/dist/web/public/assets/task-runs-BZ1brkLC.js +0 -3
  91. package/dist/web/public/assets/task-runs-BZ1brkLC.js.br +0 -0
  92. package/dist/web/public/assets/task-runs-BZ1brkLC.js.gz +0 -0
  93. package/dist/web/public/assets/tasks-BoYiBQpq.js +0 -4
  94. package/dist/web/public/assets/tasks-BoYiBQpq.js.br +0 -0
  95. package/dist/web/public/assets/tasks-BoYiBQpq.js.gz +0 -0
@@ -4,11 +4,10 @@
4
4
  // agents specifically — a bash run costs a process, an agent run costs a
5
5
  // model's context and someone's rate limit.
6
6
  import { quietLabel, splitReply } from "../core/reply.js";
7
- import { Router } from "../core/router.js";
7
+ import { logger } from "../log.js";
8
8
  import { runSource } from "./callbacks.js";
9
- import { TaskMessenger } from "./messages.js";
10
- import { TaskStore } from "./store.js";
11
9
  const MAX_ACTIVE_AGENTS = 4;
10
+ const log = logger("tasks");
12
11
  /** What a child cannot know unless told. Every session gets the chat-surface
13
12
  * contract (<pier>/AGENTS.md), task runs included — so the delegation prompt
14
13
  * says which of it does not apply here, and a supervised run how to reach the
@@ -44,11 +43,18 @@ export class AgentTaskRunner {
44
43
  this.changed = changed;
45
44
  }
46
45
  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 () => {
46
+ // Reserve reuse order before resolving the session: a slow resume must not
47
+ // let a later run pass it. Fresh runs each have their own queue key.
48
+ return this.withSession(run.targetSessionId ?? run.id, signal, async () => {
49
+ const reused = run.targetSessionId ? await this.resolveSession(run, action, signal) : undefined;
50
+ if (reused)
51
+ await this.waitUntilIdle(reused, signal);
52
+ await this.acquireSlot(run, signal);
53
+ try {
54
+ const session = reused ?? await this.resolveSession(run, action, signal);
55
+ // A reused session may have become busy while we waited for a slot.
51
56
  await this.waitUntilIdle(session, signal);
57
+ signal.throwIfAborted();
52
58
  // Task requests come seconds apart — the 1h Anthropic cache-write premium
53
59
  // never earns its 2× back, so task runs use the 5m TTL. Set here, not in
54
60
  // resolveSession: this covers create, reuse and resume alike, on
@@ -85,22 +91,16 @@ export class AgentTaskRunner {
85
91
  failure = event.error;
86
92
  }
87
93
  });
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
94
+ // Cancel the SDK turn, but release our wait even if its abort hangs.
94
95
  const abort = () => {
95
- void session.abort();
96
- rejectAborted(new Error("cancelled"));
96
+ void Promise.resolve().then(() => session.abort())
97
+ .catch((error) => log.warn(`run ${run.id} session abort failed`, error));
97
98
  };
98
99
  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
100
  try {
101
+ // Pre-abort does not fire a newly registered listener; keep the check
102
+ // inside finally's scope so it also restores retention/subscriptions.
103
+ signal.throwIfAborted();
104
104
  const turn = session.systemInput(prompt, {
105
105
  kind: "task-delegation",
106
106
  taskId: run.taskId,
@@ -110,7 +110,7 @@ export class AgentTaskRunner {
110
110
  }, "prompt");
111
111
  await Promise.resolve();
112
112
  this.messages.deliverPendingControls(run);
113
- await Promise.race([turn, abortedTurn]);
113
+ await this.untilAborted(turn, signal);
114
114
  if (signal.aborted)
115
115
  throw new Error("cancelled");
116
116
  // Before the fallback below: on a reused session that reads the
@@ -119,7 +119,7 @@ export class AgentTaskRunner {
119
119
  if (failure)
120
120
  throw new Error(failure);
121
121
  if (!text) {
122
- const history = await session.history();
122
+ const history = await this.untilAborted(session.history(), signal);
123
123
  text = [...history].reverse().find((turn) => turn.role === "assistant")?.text ?? "";
124
124
  }
125
125
  // The chat contract is injected into task sessions too, so a child's
@@ -138,15 +138,16 @@ export class AgentTaskRunner {
138
138
  signal.removeEventListener("abort", abort);
139
139
  unsubscribe();
140
140
  }
141
- });
142
- }
143
- finally {
144
- this.releaseSlot(run.id);
145
- }
141
+ }
142
+ finally {
143
+ this.releaseSlot(run.id);
144
+ }
145
+ });
146
146
  }
147
- async resolveSession(run, action) {
147
+ async resolveSession(run, action, signal) {
148
+ signal.throwIfAborted();
148
149
  if (run.targetSessionId) {
149
- return this.router.ensure({ channelId: "task", conversationId: run.targetSessionId });
150
+ return this.untilAborted(this.router.ensure({ channelId: "task", conversationId: run.targetSessionId }), signal);
150
151
  }
151
152
  const policy = action.session;
152
153
  // Definitions stored before fork was removed still say `"fork"`. Refused by
@@ -157,7 +158,8 @@ export class AgentTaskRunner {
157
158
  }
158
159
  const cwd = policy.mode === "fresh"
159
160
  ? policy.cwd
160
- : (await this.factory.find(policy.sessionId))?.cwd ?? "";
161
+ : (await this.untilAborted(this.factory.find(policy.sessionId), signal))?.cwd ?? "";
162
+ signal.throwIfAborted();
161
163
  if (!cwd)
162
164
  throw new Error("could not resolve child working directory");
163
165
  const opts = {
@@ -169,33 +171,52 @@ export class AgentTaskRunner {
169
171
  (run.sourceSessionId ? this.router.modelOf(run.sourceSessionId) : undefined),
170
172
  thinking: action.launch?.thinking,
171
173
  };
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);
174
+ const opening = this.factory.create(opts).then(async (session) => {
175
+ // SDK creation cannot be cancelled. A late session still belongs to
176
+ // this run; read back its terminal row so delivery updates are preserved.
177
+ // Remember it on the live run too: cancellation's final save can race
178
+ // this callback before it finishes writing the terminal record.
179
+ run.targetSessionId = session.id;
180
+ run.context.sessionId = session.id;
181
+ run.context.cwd = cwd;
182
+ const owner = signal.aborted ? this.store.getRun(run.id) ?? run : run;
183
+ owner.targetSessionId = run.targetSessionId;
184
+ owner.context = run.context;
185
+ try {
186
+ this.store.saveRun(owner);
187
+ this.changed(owner);
188
+ }
189
+ finally {
190
+ if (signal.aborted)
191
+ await session.dispose();
192
+ else
193
+ this.router.attach({ channelId: "task", conversationId: session.id }, session);
194
+ }
195
+ return session;
196
+ });
197
+ // A late rejection cannot reach execute's already-cancelled race.
198
+ void opening.catch((error) => {
199
+ if (signal.aborted)
200
+ log.warn(`session creation for cancelled run ${run.id} failed`, error);
201
+ });
202
+ const session = await this.untilAborted(opening, signal);
203
+ signal.throwIfAborted();
179
204
  return session;
180
205
  }
181
206
  async acquireSlot(run, signal) {
182
207
  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
- });
208
+ let wake = () => { };
209
+ try {
210
+ await this.untilAborted(new Promise((resolve) => {
211
+ wake = resolve;
212
+ this.slotWaiters.add(wake);
213
+ }), signal);
214
+ }
215
+ finally {
216
+ this.slotWaiters.delete(wake);
217
+ }
198
218
  }
219
+ signal.throwIfAborted();
199
220
  this.active.add(run.id);
200
221
  }
201
222
  releaseSlot(id) {
@@ -203,43 +224,58 @@ export class AgentTaskRunner {
203
224
  for (const wake of this.slotWaiters)
204
225
  wake();
205
226
  }
206
- async withSession(sessionId, fn) {
227
+ async untilAborted(promise, signal) {
228
+ let abort = () => { };
229
+ const cancelled = new Promise((_, reject) => {
230
+ abort = () => reject(new Error("cancelled"));
231
+ signal.addEventListener("abort", abort, { once: true });
232
+ if (signal.aborted)
233
+ abort();
234
+ });
235
+ try {
236
+ return await Promise.race([promise, cancelled]);
237
+ }
238
+ finally {
239
+ signal.removeEventListener("abort", abort);
240
+ }
241
+ }
242
+ async withSession(sessionId, signal, fn) {
243
+ signal.throwIfAborted();
207
244
  const previous = this.sessionTails.get(sessionId) ?? Promise.resolve();
208
245
  let release = () => { };
209
246
  const gate = new Promise((resolve) => { release = resolve; });
210
247
  const tail = previous.then(() => gate);
211
248
  this.sessionTails.set(sessionId, tail);
212
- await previous;
249
+ // Even if this waiter is cancelled, its tail still includes its predecessor.
250
+ // Only delete after that tail drains, or a new arrival could skip the queue.
251
+ void tail.then(() => {
252
+ if (this.sessionTails.get(sessionId) === tail)
253
+ this.sessionTails.delete(sessionId);
254
+ });
213
255
  try {
256
+ await this.untilAborted(previous, signal);
257
+ signal.throwIfAborted();
214
258
  return await fn();
215
259
  }
216
260
  finally {
217
261
  release();
218
- if (this.sessionTails.get(sessionId) === tail)
219
- this.sessionTails.delete(sessionId);
220
262
  }
221
263
  }
222
264
  async waitUntilIdle(session, signal) {
223
265
  // The session's own event stream is the only busy/idle signal — no polling.
224
266
  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
- });
267
+ let unsubscribe = () => { };
268
+ try {
269
+ await this.untilAborted(new Promise((resolve) => {
270
+ unsubscribe = session.subscribe((event) => {
271
+ if (event.type === "state" && event.state === "idle")
272
+ resolve();
273
+ });
274
+ }), signal);
275
+ }
276
+ finally {
277
+ unsubscribe();
278
+ }
243
279
  }
244
280
  }
245
281
  }
@@ -1,8 +1,6 @@
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
4
  /** How a run is named to the session that gets its result: the run id, and the
7
5
  * session that did the work — a relayer's next move is a deep link to it
8
6
  * (`#/session/<id>`), and without this line that costs a second tool call.
@@ -28,7 +26,7 @@ export function runResultText(run) {
28
26
  if (run.result?.type === "watch")
29
27
  result = "Watch condition did not match";
30
28
  if (result.length > 8000)
31
- result = `${result.slice(0, 8000)}\n[truncated — task tool get run_id ${run.id} returns the full text]`;
29
+ result = `${result.slice(0, 8000)}\n[truncated — task tool recover run_id ${run.id} with a reason returns the full text]`;
32
30
  return result;
33
31
  }
34
32
  export class TaskCallbacks {
@@ -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(),
@@ -3,11 +3,7 @@
3
3
  // while it goes, settled exactly once. Command and agent work lives in
4
4
  // command.ts and agent.ts; this file owns the lifecycle they share.
5
5
  import { logger } from "../log.js";
6
- import { AgentTaskRunner } from "./agent.js";
7
- import { TaskCallbacks } from "./callbacks.js";
8
6
  import { runBash } from "./command.js";
9
- import { TaskDefinitions } from "./definitions.js";
10
- import { TaskStore } from "./store.js";
11
7
  const log = logger("tasks");
12
8
  export class TaskExecution {
13
9
  store;
@@ -40,6 +36,8 @@ export class TaskExecution {
40
36
  let timedOut = false;
41
37
  let cause;
42
38
  const timeout = setTimeout(() => {
39
+ if (controller.signal.aborted)
40
+ return;
43
41
  timedOut = true;
44
42
  controller.abort();
45
43
  }, run.context.definition.timeoutSeconds * 1000);
@@ -58,6 +56,7 @@ export class TaskExecution {
58
56
  }
59
57
  if (run.matched !== false)
60
58
  run.result = await this.executeAction(run, controller.signal);
59
+ controller.signal.throwIfAborted();
61
60
  run.state = "succeeded";
62
61
  if (definition.trigger.type === "watch" && !run.resumedFromRunId && definition.trigger.mode === "once" && run.matched) {
63
62
  // As the definition's own creator: a one-shot watch retiring itself is
@@ -112,6 +111,7 @@ export class TaskExecution {
112
111
  }
113
112
  }
114
113
  async executeAction(run, signal) {
114
+ signal.throwIfAborted();
115
115
  const action = run.context.definition.action;
116
116
  if (action.type === "bash") {
117
117
  this.markRunning(run);
@@ -143,6 +143,8 @@ export class TaskExecution {
143
143
  rejectWait(new Error("cancelled"));
144
144
  };
145
145
  signal.addEventListener("abort", onAbort, { once: true });
146
+ if (signal.aborted)
147
+ onAbort();
146
148
  try {
147
149
  const done = await Promise.race([this.host.waitForRun(child.id), aborted]);
148
150
  if (done.state !== "succeeded")
@@ -2,12 +2,10 @@
2
2
  // separate endings into one answer — when the join condition is met, which
3
3
  // members are cancelled, and the single aggregated callback that goes back.
4
4
  // Delivering it is the outbox's job; deciding it is this file's.
5
- import { Router } from "../core/router.js";
6
5
  import { logger } from "../log.js";
7
6
  import { runRef, runResultText } from "./callbacks.js";
8
7
  import { newId } from "./definitions.js";
9
8
  import { Outbox } from "./outbox.js";
10
- import { TaskStore } from "./store.js";
11
9
  import { isTerminal } from "./types.js";
12
10
  const log = logger("tasks");
13
11
  /** Core-owned fan-out join: members run detached, the group delivers one
@@ -16,11 +14,13 @@ export class TaskGroups {
16
14
  store;
17
15
  host;
18
16
  changed;
17
+ unreachable;
19
18
  outbox;
20
19
  constructor(store, router, host, changed, unreachable) {
21
20
  this.store = store;
22
21
  this.host = host;
23
22
  this.changed = changed;
23
+ this.unreachable = unreachable;
24
24
  this.outbox = new Outbox(router, {
25
25
  id: (group) => group.id,
26
26
  reload: (id) => this.store.getGroup(id),
@@ -38,21 +38,21 @@ export class TaskGroups {
38
38
  }
39
39
  /** Enqueues every member or none: a partially started group is worse than
40
40
  * a rejected one. */
41
- runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId) {
42
- const group = this.create(join, callerSessionId, callbackSessionId);
43
- const runs = [];
44
- try {
45
- for (const definition of definitions) {
46
- runs.push(this.host.startMember(definition.id, group.id, callerSessionId, parentRunId));
47
- }
48
- }
49
- catch (error) {
50
- log.warn(`group ${group.id} rolled back after ${String(runs.length)} members`, error);
51
- for (const run of runs)
52
- this.host.cancel(run.id);
53
- throw error;
54
- }
55
- this.setMembers(group, runs.map((run) => run.id));
41
+ runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId, callbackMode) {
42
+ if (definitions.length < 2)
43
+ throw new Error("a task group needs at least 2 members");
44
+ const { group, runs } = this.store.transact(() => {
45
+ const group = this.create(join, callerSessionId, callbackSessionId, callbackMode);
46
+ const runs = definitions.map((definition) => this.host.prepareMember(definition.id, group.id, callerSessionId, parentRunId));
47
+ group.memberRunIds = runs.map((run) => run.id);
48
+ this.store.saveGroup(group);
49
+ return { group, runs };
50
+ });
51
+ this.changed(group);
52
+ for (const run of runs)
53
+ this.host.startMember(run);
54
+ // Skipped members do not pass through execution's settled hook.
55
+ this.evaluate(this.get(group.id));
56
56
  return { group: this.get(group.id), runs };
57
57
  }
58
58
  members(id) {
@@ -64,12 +64,13 @@ export class TaskGroups {
64
64
  this.host.cancel(runId);
65
65
  return this.get(id);
66
66
  }
67
- create(join, invokedBySessionId, callbackSessionId) {
68
- const group = {
67
+ create(join, invokedBySessionId, callbackSessionId, callbackMode) {
68
+ return {
69
69
  id: newId(),
70
70
  join,
71
71
  invokedBySessionId,
72
72
  callbackSessionId,
73
+ ...(callbackMode === "steer" ? { callbackMode } : {}),
73
74
  memberRunIds: [],
74
75
  winnerRunId: null,
75
76
  callbackState: null,
@@ -79,13 +80,6 @@ export class TaskGroups {
79
80
  createdAt: Date.now(),
80
81
  finishedAt: null,
81
82
  };
82
- this.store.saveGroup(group);
83
- return group;
84
- }
85
- setMembers(group, runIds) {
86
- group.memberRunIds = runIds;
87
- this.store.saveGroup(group);
88
- this.changed(group);
89
83
  }
90
84
  get(id) {
91
85
  const group = this.store.getGroup(id);
@@ -109,6 +103,21 @@ export class TaskGroups {
109
103
  }
110
104
  }
111
105
  evaluate(group) {
106
+ if (group.finishedAt !== null)
107
+ return;
108
+ // Old versions could leave an empty group when admission failed. Retire
109
+ // that broken record without inventing a successful zero-member join.
110
+ if (group.memberRunIds.length === 0) {
111
+ group.finishedAt = Date.now();
112
+ group.callbackState = group.callbackSessionId ? "abandoned" : null;
113
+ group.callbackError = "group admission failed before its members were recorded";
114
+ this.store.saveGroup(group);
115
+ this.changed(group);
116
+ log.error(`group ${group.id}: ${group.callbackError}`);
117
+ if (group.callbackSessionId)
118
+ this.unreachable(group.callbackSessionId, `task group ${group.id}`, group.callbackError);
119
+ return;
120
+ }
112
121
  const members = group.memberRunIds.map((id) => this.host.getRun(id));
113
122
  if (group.join === "first") {
114
123
  const winner = members.find((run) => isTerminal(run.state));
@@ -4,12 +4,9 @@
4
4
  // the two ends are different sessions and either may be mid-turn, gone, or
5
5
  // finished — an undelivered message is retried, expired and *said*, never
6
6
  // dropped (§5b).
7
- import { EventHub } from "../core/hub.js";
8
- import { Router } from "../core/router.js";
9
7
  import { logger } from "../log.js";
10
8
  import { runSource } from "./callbacks.js";
11
9
  import { newId } from "./definitions.js";
12
- import { TaskStore } from "./store.js";
13
10
  import { isTerminal, MAX_DELIVERY_ATTEMPTS, retryDelay, undeliverable } from "./types.js";
14
11
  const log = logger("tasks");
15
12
  const MAX_MESSAGE_LENGTH = 16 * 1024;
@@ -25,18 +22,20 @@ export class TaskMessenger {
25
22
  store;
26
23
  router;
27
24
  hub;
28
- resumeRun;
25
+ prepareResume;
29
26
  unreachable;
27
+ startRun;
30
28
  constructor(store, router, hub,
31
- /** Continues a terminal child with a supervisor reply as its prompt. */
32
- resumeRun,
29
+ /** Prepares a continuation; it starts only after the reply commits. */
30
+ prepareResume,
33
31
  /** Reports a delivery nobody can complete (service.ts owns the surfaces). */
34
- unreachable) {
32
+ unreachable, startRun) {
35
33
  this.store = store;
36
34
  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;
40
39
  }
41
40
  expirePending() {
42
41
  for (const message of this.store.expirePendingMessages())
@@ -49,6 +48,7 @@ export class TaskMessenger {
49
48
  /** A manual continuation supersedes an unanswered decision (design 04):
50
49
  * one continuation per run, never two racing ones. */
51
50
  expireDecisions(runId, reason) {
51
+ const expired = [];
52
52
  for (const message of this.store.listMessages(runId)) {
53
53
  if (message.kind !== "decision" || (message.state !== "pending" && message.state !== "delivered"))
54
54
  continue;
@@ -56,8 +56,9 @@ export class TaskMessenger {
56
56
  message.error = reason;
57
57
  message.answeredAt = Date.now();
58
58
  this.store.saveMessage(message);
59
- this.changed(message);
59
+ expired.push(message);
60
60
  }
61
+ return expired;
61
62
  }
62
63
  list(runId) {
63
64
  return this.store.listMessages(runId);
@@ -67,6 +68,7 @@ export class TaskMessenger {
67
68
  }
68
69
  async control(run, fromSessionId, kind, content) {
69
70
  const message = this.create(run, kind, fromSessionId, run.targetSessionId ?? "", content, null);
71
+ this.changed(message);
70
72
  if (run.targetSessionId)
71
73
  this.deliver(message, run, run.targetSessionId);
72
74
  return this.require(message.id);
@@ -139,6 +141,7 @@ export class TaskMessenger {
139
141
  throw new Error("run already has a pending supervisor decision");
140
142
  }
141
143
  const message = this.create(run, reason, fromSessionId, run.invokedBySessionId, content, null);
144
+ this.changed(message);
142
145
  this.deliver(message, run, run.invokedBySessionId);
143
146
  return this.require(message.id);
144
147
  }
@@ -161,25 +164,28 @@ export class TaskMessenger {
161
164
  const run = this.store.getRun(question.runId);
162
165
  if (!run)
163
166
  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);
167
+ // A rejected continuation must leave the question answerable. Persist the
168
+ // answer and its queued run together, before either publishes or executes.
169
+ const { reply, continuation } = this.store.transact(() => {
170
+ const reply = this.create(run, "reply", fromSessionId, question.fromSessionId, text, question.id);
171
+ question.state = "answered";
172
+ question.answeredAt = Date.now();
173
+ this.store.saveMessage(question);
174
+ const continuation = isTerminal(run.state)
175
+ ? this.prepareResume(run.id, this.format(reply, run), fromSessionId)
176
+ : undefined;
177
+ if (continuation) {
178
+ reply.resumeRunId = continuation.id;
179
+ this.store.saveMessage(reply);
180
+ }
181
+ return { reply, continuation };
182
+ });
168
183
  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")) {
184
+ this.changed(reply);
185
+ if (continuation)
186
+ this.startRun(continuation);
187
+ else if (run.targetSessionId)
172
188
  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
- }
183
189
  return this.require(reply.id);
184
190
  }
185
191
  create(run, kind, fromSessionId, toSessionId, content, replyTo) {
@@ -200,7 +206,6 @@ export class TaskMessenger {
200
206
  nextAttemptAt: null,
201
207
  };
202
208
  this.store.saveMessage(message);
203
- this.changed(message);
204
209
  return message;
205
210
  }
206
211
  /** Never awaits the recipient: the seam's `systemInput` settles with the turn
@@ -377,6 +382,7 @@ export class TaskMessenger {
377
382
  throw new Error(`unknown task message: ${id}`);
378
383
  return message;
379
384
  }
385
+ /** Publish only after any transaction changing this message has committed. */
380
386
  changed(message) {
381
387
  this.hub.emitWorkspace({ type: "task-message-changed", runId: message.runId, messageId: message.id });
382
388
  }