@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
@@ -9,47 +9,25 @@ import { isThinkingLevel } from "../core/types.js";
9
9
  import { EventHub } from "../core/hub.js";
10
10
  import { Router } from "../core/router.js";
11
11
  import { TaskStore } from "./store.js";
12
- const DEFAULT_TIMEOUT = 900;
12
+ // An agent run that implements, tests and commits one concern takes 15–60
13
+ // minutes; the default is the runaway ceiling, not the pace of normal work.
14
+ const DEFAULT_TIMEOUT = 3600;
13
15
  const MIN_WATCH_SECONDS = 5;
14
- /** Crockford's base32, lowercased: i, l, o and u are gone — the three that
15
- * misread as 1/0 and the one that completes most accidental words — and one
16
- * case throughout, so a model re-types an id it read verbatim. */
16
+ /** Crockford's base32, lowercased: i, l, o and u are gone, and one case
17
+ * throughout, so a model re-types an id it read verbatim. */
17
18
  const ID_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
18
- /** The one byte → symbol step, exported only so a test can walk all 256 byte
19
- * values: a repeated or omitted character in the alphabet above biases every
20
- * id ever minted, and no sample of finished ids can show it. */
19
+ /** Exported only so a test can walk all 256 byte values for alphabet bias. */
21
20
  export const idSymbol = (byte) => ID_ALPHABET.charAt(byte & 31);
22
- /** The id every task record is minted with — runs, definitions, groups and
23
- * messages alike — because these ids ride through model context constantly:
24
- * every run summary, callback, get, steer and reply echoes one, and a UUID
25
- * spends ~12 tokens where this spends ~6. Nothing parses or orders by them —
26
- * every comparison in the area is string equality and every listing orders by
27
- * a timestamp column — so rows minted as UUIDs before this keep working
28
- * untouched; there is nothing to migrate. It lives here rather than in
29
- * types.ts because the browser type-checks that file and it stays node-free.
30
- *
31
- * Sixteen characters, 80 bits (`& 31` is unbiased because 256 is a multiple
32
- * of 32). Sixty would have read the same and cost a token less, but a watch
33
- * task on a 5-second interval mints ~6M runs a year, and a collision here is
34
- * not an error: `saveRun`'s ON CONFLICT DO UPDATE would quietly overwrite the
35
- * older run with the newer one. Four more characters buy ~16 million times
36
- * the headroom for four bytes — cheaper than the alternative fix, which is a
37
- * strict INSERT and a retry loop on every one of the four mint sites. */
21
+ /** Every task record's id. These ride through model context constantly, and a
22
+ * UUID spends ~12 tokens where this spends ~6. Sixteen characters (80 bits;
23
+ * `& 31` is unbiased): a watch task mints ~6M runs a year, and a collision is
24
+ * not an error — `saveRun`'s ON CONFLICT DO UPDATE would quietly overwrite. */
38
25
  export const newId = () => Array.from(randomBytes(16), idSymbol).join("");
39
- /**
40
- * Seam decision (tasks/): a definition Pier's own code created is reconciled by
41
- * that code, and edited by nobody.
42
- *
43
- * `creator` is `"http"` for the Console and `session:<id>` for the task tool.
44
- * Anything else is an instance-layer owner — today the tools update task
45
- * (src/tools-task.ts), whose script a switch in Settings runs on demand and
46
- * cron runs nightly. Both
47
- * public surfaces could rename it, point it at another script, pause it or
48
- * archive it, and the switch would go on claiming Pier keeps the tools current
49
- * while the run did something else entirely. The owner names itself in `by`;
50
- * neither the routes nor the tool has a `by` to pass, so this one function
51
- * closes both.
52
- */
26
+ /** A definition Pier's own code created is reconciled by that code and edited
27
+ * by nobody: `creator` is `"http"` (Console) or `session:<id>` (task tool);
28
+ * anything else is an instance-layer owner, which names itself in `by`.
29
+ * Otherwise a public surface could repoint the tools task while its switch
30
+ * went on claiming Pier keeps the tools current. */
53
31
  const ownerOf = (task) => task.creator === "http" || task.creator.startsWith("session:") ? null : task.creator;
54
32
  export const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
55
33
  ? value
@@ -143,9 +121,8 @@ export class TaskDefinitions {
143
121
  return task;
144
122
  }
145
123
  async create(raw, creator = "http", kind = "task") {
146
- // The tool schema marks trigger optional, so a trigger-less new definition
147
- // means manual. Update keeps requiring it: replacing a cron task with a
148
- // draft that forgot its trigger must not silently unschedule it.
124
+ // A trigger-less new definition means manual. Update keeps requiring it:
125
+ // a draft that forgot its trigger must not silently unschedule a cron task.
149
126
  const value = record(raw);
150
127
  const draft = await this.parseDraft(value && value.trigger === undefined ? { ...value, trigger: { type: "manual" } } : raw, creator);
151
128
  const now = Date.now();
@@ -176,19 +153,24 @@ export class TaskDefinitions {
176
153
  async update(id, raw, by) {
177
154
  const old = this.get(id);
178
155
  this.assertOwner(old, by, "edited");
179
- if (old.archived)
180
- throw new Error("archived tasks cannot be edited");
181
156
  const draft = await this.parseDraft(raw, ownerOf(old) ? by : undefined);
157
+ // Validation awaits, so the record read above is a guess by now: archive()
158
+ // or another edit may have landed, and the spread below would restore it.
159
+ const current = this.get(id);
160
+ if (current.archived)
161
+ throw new Error("archived tasks cannot be edited");
162
+ if (current.revision !== old.revision)
163
+ throw new Error("task was edited while this draft was validated");
182
164
  const now = Date.now();
183
165
  const task = {
184
- ...old,
166
+ ...current,
185
167
  name: draft.name,
186
168
  description: draft.description ?? "",
187
- enabled: draft.enabled ?? old.enabled,
188
- revision: old.revision + 1,
169
+ enabled: draft.enabled ?? current.enabled,
170
+ revision: current.revision + 1,
189
171
  trigger: draft.trigger,
190
172
  action: draft.action,
191
- callback: draft.callback ?? old.callback,
173
+ callback: draft.callback ?? current.callback,
192
174
  timeoutSeconds: draft.timeoutSeconds ?? DEFAULT_TIMEOUT,
193
175
  nextRunAt: null,
194
176
  updatedAt: now,
@@ -228,20 +210,16 @@ export class TaskDefinitions {
228
210
  this.store.saveTask(task);
229
211
  }
230
212
  }
231
- claimDue(now) {
232
- const due = [];
233
- // The store narrows to the tasks with a next run at or before `now`; the
234
- // JSON is still the record, so its own fields decide.
235
- for (const task of this.store.listDueTasks(now)) {
236
- if (!task.enabled || task.archived || task.nextRunAt === null || task.nextRunAt > now)
237
- continue;
238
- task.nextRunAt = nextRunAt(task.trigger, now);
239
- this.store.saveTask(task);
240
- due.push(task);
241
- }
242
- if (due.length)
243
- this.changed();
244
- return due;
213
+ /** The store narrows by index; the JSON is still the record, so its fields decide. */
214
+ due(now) {
215
+ return this.store.listDueTasks(now)
216
+ .filter((task) => task.enabled && !task.archived && task.nextRunAt !== null && task.nextRunAt <= now);
217
+ }
218
+ /** Committed with the run this occurrence queues: a failed enqueue rolls the
219
+ * advance back, so the occurrence stays due instead of being skipped. */
220
+ advance(task, now) {
221
+ task.nextRunAt = nextRunAt(task.trigger, now);
222
+ this.store.saveTask(task);
245
223
  }
246
224
  async sessionExists(sessionId) {
247
225
  return this.router.stateOf(sessionId) !== undefined ||
@@ -332,8 +310,7 @@ export class TaskDefinitions {
332
310
  session = { mode: "fresh", cwd };
333
311
  }
334
312
  else {
335
- // Validation never mutates: a dedicated session is created explicitly
336
- // (POST /api/sessions) and then referenced with mode:"reuse".
313
+ // Validation never mutates: a session is created explicitly and then reused.
337
314
  throw new Error('agent session policy required, e.g. {"mode":"fresh","cwd":"/abs/path"} or {"mode":"reuse","sessionId":"..."}');
338
315
  }
339
316
  if (session.mode === "reuse" && launch)
@@ -1,13 +1,7 @@
1
- // One queued run carried to a result: dispatched by action kind (a bash
2
- // script, an agent session, another task or an owned system action), abortable
3
- // while it goes, settled exactly once. Command and agent work lives in
4
- // command.ts and agent.ts; this file owns the lifecycle they share.
1
+ // One queued run carried to a result: dispatched by action kind, abortable,
2
+ // settled exactly once. The lifecycle command.ts and agent.ts share.
5
3
  import { logger } from "../log.js";
6
- import { AgentTaskRunner } from "./agent.js";
7
- import { TaskCallbacks } from "./callbacks.js";
8
4
  import { runBash } from "./command.js";
9
- import { TaskDefinitions } from "./definitions.js";
10
- import { TaskStore } from "./store.js";
11
5
  const log = logger("tasks");
12
6
  export class TaskExecution {
13
7
  store;
@@ -39,15 +33,27 @@ export class TaskExecution {
39
33
  this.controllers.set(run.id, controller);
40
34
  let timedOut = false;
41
35
  let cause;
42
- const timeout = setTimeout(() => {
43
- timedOut = true;
44
- controller.abort();
45
- }, run.context.definition.timeoutSeconds * 1000);
46
- timeout.unref();
36
+ let timeout;
37
+ // The budget is the run's own: waiting for an agent slot or a busy session
38
+ // (agent.ts) is bounded by cancellation and restart, never by the timeout.
39
+ const start = () => {
40
+ timeout ??= setTimeout(() => {
41
+ if (controller.signal.aborted)
42
+ return;
43
+ timedOut = true;
44
+ controller.abort();
45
+ }, run.context.definition.timeoutSeconds * 1000).unref();
46
+ if (run.state === "running")
47
+ return;
48
+ run.state = "running";
49
+ run.startedAt = Date.now();
50
+ this.store.saveRun(run);
51
+ this.host.changed(run);
52
+ };
47
53
  try {
48
54
  const { definition } = run.context;
49
55
  if (definition.trigger.type === "watch" && !run.resumedFromRunId) {
50
- this.markRunning(run);
56
+ start();
51
57
  run.probe = await runBash(definition.trigger.script, definition.trigger.cwd, run.input, controller.signal);
52
58
  run.matched = run.probe.exitCode === 0;
53
59
  this.store.saveRun(run);
@@ -57,19 +63,17 @@ export class TaskExecution {
57
63
  throw new Error(`watch probe exited ${String(run.probe.exitCode)}`);
58
64
  }
59
65
  if (run.matched !== false)
60
- run.result = await this.executeAction(run, controller.signal);
66
+ run.result = await this.executeAction(run, controller.signal, start);
67
+ controller.signal.throwIfAborted();
61
68
  run.state = "succeeded";
62
69
  if (definition.trigger.type === "watch" && !run.resumedFromRunId && definition.trigger.mode === "once" && run.matched) {
63
- // As the definition's own creator: a one-shot watch retiring itself is
64
- // the task layer keeping its own promise, not a surface editing
65
- // somebody's task, and the owner guard (definitions.ts) would
66
- // otherwise fail the run that had just succeeded.
70
+ // As the definition's own creator, or the owner guard (definitions.ts)
71
+ // would fail the run that just succeeded.
67
72
  this.definitions.setEnabled(definition.id, false, definition.creator);
68
73
  }
69
74
  }
70
75
  catch (error) {
71
- // A killed child reports `exited null`, not `cancelled`: report why we
72
- // aborted instead of how the corpse looked.
76
+ // A killed child reports `exited null`; report why we aborted instead.
73
77
  const aborted = controller.signal.aborted;
74
78
  run.state = aborted ? (timedOut ? "failed" : "cancelled") : "failed";
75
79
  run.error = timedOut ? "task timed out" : aborted ? "cancelled" : String(error);
@@ -80,10 +84,8 @@ export class TaskExecution {
80
84
  run.finishedAt = Date.now();
81
85
  const seconds = ((run.finishedAt - (run.startedAt ?? run.queuedAt)) / 1000).toFixed(1);
82
86
  const settled = `run ${run.id} (${run.context.definition.name}) ${run.state} in ${seconds}s`;
83
- // A scheduled run has no one watching it: the run row is the only other
84
- // place this exists, and nobody opens the Console to find out it failed.
85
- // A watch probe that did not match is the opposite case — it fires on
86
- // every interval and at info would be most of the journal.
87
+ // Nobody opens the Console to find out a scheduled run failed. A watch
88
+ // probe that did not match fires every interval and would be most of the journal.
87
89
  if (run.state === "failed")
88
90
  log.error(settled, cause);
89
91
  else if (run.matched === false)
@@ -99,22 +101,30 @@ export class TaskExecution {
99
101
  this.store.saveRun(run);
100
102
  }
101
103
  catch (err) {
102
- // Waiters settle from the in-memory run below; the callback and any
103
- // group join read the stale row and wait — the next boot's interrupt
104
- // sweep re-marks it and delivers then. Named here so that delay has
105
- // an explanation.
104
+ // Waiters settle from the in-memory run; the callback reads the stale
105
+ // row and waits for the next boot's interrupt sweep.
106
106
  log.error(`run ${run.id} final save failed — callback/join deferred to next boot`, err);
107
107
  }
108
+ if (run.matched === false) {
109
+ try {
110
+ this.store.pruneUnmatchedProbes(run.taskId);
111
+ }
112
+ catch (err) {
113
+ // Housekeeping: a waiter on this run is owed its settlement regardless.
114
+ log.warn(`run ${run.id} probe retention failed`, err);
115
+ }
116
+ }
108
117
  this.host.changed(run);
109
118
  this.host.settled(run);
110
119
  if (run.callbackState === "pending")
111
120
  void this.callbacks.deliver(run);
112
121
  }
113
122
  }
114
- async executeAction(run, signal) {
123
+ async executeAction(run, signal, start) {
124
+ signal.throwIfAborted();
115
125
  const action = run.context.definition.action;
116
126
  if (action.type === "bash") {
117
- this.markRunning(run);
127
+ start();
118
128
  run.context.cwd = action.cwd;
119
129
  this.store.saveRun(run);
120
130
  const result = await runBash(action.script, action.cwd, run.input, signal);
@@ -126,7 +136,7 @@ export class TaskExecution {
126
136
  return output;
127
137
  }
128
138
  if (action.type === "system") {
129
- this.markRunning(run);
139
+ start();
130
140
  const handler = this.definitions.systemAction(action.name, run.context.definition.creator);
131
141
  signal.throwIfAborted();
132
142
  const text = await handler(signal);
@@ -134,7 +144,7 @@ export class TaskExecution {
134
144
  return { type: "system", text };
135
145
  }
136
146
  if (action.type === "task") {
137
- this.markRunning(run);
147
+ start();
138
148
  const child = this.host.runChild(action.taskId, run);
139
149
  let rejectWait = (reason) => { void reason; };
140
150
  const aborted = new Promise((_, reject) => { rejectWait = reject; });
@@ -143,6 +153,8 @@ export class TaskExecution {
143
153
  rejectWait(new Error("cancelled"));
144
154
  };
145
155
  signal.addEventListener("abort", onAbort, { once: true });
156
+ if (signal.aborted)
157
+ onAbort();
146
158
  try {
147
159
  const done = await Promise.race([this.host.waitForRun(child.id), aborted]);
148
160
  if (done.state !== "succeeded")
@@ -153,14 +165,6 @@ export class TaskExecution {
153
165
  signal.removeEventListener("abort", onAbort);
154
166
  }
155
167
  }
156
- return this.agent.execute(run, action, signal, () => this.markRunning(run));
157
- }
158
- markRunning(run) {
159
- if (run.state === "running")
160
- return;
161
- run.state = "running";
162
- run.startedAt = Date.now();
163
- this.store.saveRun(run);
164
- this.host.changed(run);
168
+ return this.agent.execute(run, action, signal, start);
165
169
  }
166
170
  }
@@ -1,13 +1,9 @@
1
- // The fan-out join: members start detached, and the group is what turns their
2
- // separate endings into one answer — when the join condition is met, which
3
- // members are cancelled, and the single aggregated callback that goes back.
4
- // Delivering it is the outbox's job; deciding it is this file's.
5
- import { Router } from "../core/router.js";
1
+ // The fan-out join: when the join condition is met, which members are
2
+ // cancelled, and the single aggregated callback. Delivering it is the outbox's job.
6
3
  import { logger } from "../log.js";
7
4
  import { runRef, runResultText } from "./callbacks.js";
8
5
  import { newId } from "./definitions.js";
9
6
  import { Outbox } from "./outbox.js";
10
- import { TaskStore } from "./store.js";
11
7
  import { isTerminal } from "./types.js";
12
8
  const log = logger("tasks");
13
9
  /** Core-owned fan-out join: members run detached, the group delivers one
@@ -16,43 +12,44 @@ export class TaskGroups {
16
12
  store;
17
13
  host;
18
14
  changed;
15
+ unreachable;
19
16
  outbox;
20
17
  constructor(store, router, host, changed, unreachable) {
21
18
  this.store = store;
22
19
  this.host = host;
23
20
  this.changed = changed;
21
+ this.unreachable = unreachable;
24
22
  this.outbox = new Outbox(router, {
25
23
  id: (group) => group.id,
26
24
  reload: (id) => this.store.getGroup(id),
27
25
  save: (group) => { this.store.saveGroup(group); },
28
26
  changed,
29
- // A group names itself in the origin where a run names its run id, so the
30
- // engine's transcript proof works unchanged; the console's run route
31
- // resolves that id to the group's members (web/ui/task-runs.ts).
27
+ // A group names itself where a run names its run id, so the engine's
28
+ // transcript proof works unchanged.
32
29
  input: (groups) => ({
33
30
  text: this.text(groups[0]),
34
31
  origin: { kind: "task-callback", taskId: groups[0].id, runId: groups[0].id, sourceSessionId: null },
35
32
  }),
36
- describe: (group) => `the result of a ${String(group.memberRunIds.length)}-run group`,
37
- }, unreachable);
33
+ abandoned: (group, sessionId, why) => unreachable(sessionId, `the result of a ${String(group.memberRunIds.length)}-run group`, why),
34
+ });
38
35
  }
39
36
  /** Enqueues every member or none: a partially started group is worse than
40
37
  * 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));
38
+ runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId, callbackMode) {
39
+ if (definitions.length < 2)
40
+ throw new Error("a task group needs at least 2 members");
41
+ const { group, runs } = this.store.transact(() => {
42
+ const group = this.create(join, callerSessionId, callbackSessionId, callbackMode);
43
+ const runs = definitions.map((definition) => this.host.prepareMember(definition.id, group.id, callerSessionId, parentRunId));
44
+ group.memberRunIds = runs.map((run) => run.id);
45
+ this.store.saveGroup(group);
46
+ return { group, runs };
47
+ });
48
+ this.changed(group);
49
+ for (const run of runs)
50
+ this.host.startMember(run);
51
+ // Skipped members do not pass through execution's settled hook.
52
+ this.evaluate(this.get(group.id));
56
53
  return { group: this.get(group.id), runs };
57
54
  }
58
55
  members(id) {
@@ -64,12 +61,13 @@ export class TaskGroups {
64
61
  this.host.cancel(runId);
65
62
  return this.get(id);
66
63
  }
67
- create(join, invokedBySessionId, callbackSessionId) {
68
- const group = {
64
+ create(join, invokedBySessionId, callbackSessionId, callbackMode) {
65
+ return {
69
66
  id: newId(),
70
67
  join,
71
68
  invokedBySessionId,
72
69
  callbackSessionId,
70
+ ...(callbackMode === "steer" ? { callbackMode } : {}),
73
71
  memberRunIds: [],
74
72
  winnerRunId: null,
75
73
  callbackState: null,
@@ -79,13 +77,6 @@ export class TaskGroups {
79
77
  createdAt: Date.now(),
80
78
  finishedAt: null,
81
79
  };
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
80
  }
90
81
  get(id) {
91
82
  const group = this.store.getGroup(id);
@@ -109,6 +100,21 @@ export class TaskGroups {
109
100
  }
110
101
  }
111
102
  evaluate(group) {
103
+ if (group.finishedAt !== null)
104
+ return;
105
+ // An empty group is a broken record; retire it without inventing a
106
+ // successful zero-member join.
107
+ if (group.memberRunIds.length === 0) {
108
+ group.finishedAt = Date.now();
109
+ group.callbackState = group.callbackSessionId ? "abandoned" : null;
110
+ group.callbackError = "group admission failed before its members were recorded";
111
+ this.store.saveGroup(group);
112
+ this.changed(group);
113
+ log.error(`group ${group.id}: ${group.callbackError}`);
114
+ if (group.callbackSessionId)
115
+ this.unreachable(group.callbackSessionId, `task group ${group.id}`, group.callbackError);
116
+ return;
117
+ }
112
118
  const members = group.memberRunIds.map((id) => this.host.getRun(id));
113
119
  if (group.join === "first") {
114
120
  const winner = members.find((run) => isTerminal(run.state));