@timqi/pier 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +6 -15
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +42 -64
  5. package/dist/agent/listing.js +39 -92
  6. package/dist/agent/pi.js +103 -252
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +7 -23
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +12 -34
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -264
  48. package/dist/core/types.js +4 -0
  49. package/dist/db.js +88 -272
  50. package/dist/drain.js +57 -50
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +4 -12
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +84 -183
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +18 -45
  65. package/dist/service.js +31 -73
  66. package/dist/settings.js +19 -63
  67. package/dist/tasks/agent.js +24 -45
  68. package/dist/tasks/callbacks.js +8 -16
  69. package/dist/tasks/command.js +2 -6
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +41 -39
  72. package/dist/tasks/groups.js +8 -11
  73. package/dist/tasks/messages.js +88 -155
  74. package/dist/tasks/outbox.js +33 -54
  75. package/dist/tasks/routes.js +4 -7
  76. package/dist/tasks/runs.js +4 -9
  77. package/dist/tasks/service.js +29 -42
  78. package/dist/tasks/store.js +36 -27
  79. package/dist/tasks/tool.js +59 -60
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +98 -325
  82. package/dist/update.js +20 -43
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +35 -82
  89. package/dist/web/providers.js +5 -11
  90. package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-B89_hH7q.js} +1 -1
  91. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  92. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  93. package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-BeKW0ZXK.js} +1 -1
  94. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  95. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
  97. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  98. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  99. package/dist/web/public/assets/index-DzXDXra_.js +85 -0
  100. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  101. package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cwy0mN8i.js} +1 -1
  106. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  108. package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-DzZLmujq.js} +1 -1
  109. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  110. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  111. package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-BCakxFk8.js} +1 -1
  112. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
  115. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
  117. package/dist/web/public/index.html +30 -16
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/sw.js +14 -2
  121. package/dist/web/public/sw.js.br +0 -0
  122. package/dist/web/public/sw.js.gz +0 -0
  123. package/dist/web/push.js +55 -77
  124. package/dist/web/route.js +3 -7
  125. package/dist/web/server.js +109 -190
  126. package/dist/web/session-state.js +13 -53
  127. package/dist/web/types.js +2 -4
  128. package/dist/web/webpush.js +10 -25
  129. package/docs/deploy.md +115 -330
  130. package/package.json +1 -1
  131. package/skills/pier-boards/SKILL.md +81 -160
  132. package/skills/pier-help/SKILL.md +23 -20
  133. package/skills/pier-slack/SKILL.md +2 -2
  134. package/skills/pier-tasks/SKILL.md +23 -15
  135. package/dist/config-sync-fetch.js +0 -84
  136. package/dist/limits.js +0 -14
  137. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  138. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  139. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  140. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  141. package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
  142. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  143. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  144. package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
  145. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  146. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  147. package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
  148. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  149. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  150. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  151. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  152. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  153. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  154. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  155. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  156. package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
  157. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  158. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
@@ -1,15 +1,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
3
  import { Outbox } from "./outbox.js";
4
- /** How a run is named to the session that gets its result: the run id, and the
5
- * session that did the work — a relayer's next move is a deep link to it
6
- * (`#/session/<id>`), and without this line that costs a second tool call.
7
- * 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. */
8
6
  export const runRef = (run) => `Run: ${run.id}${run.targetSessionId ? ` / Session: ${run.targetSessionId}` : ""}`;
9
- /** What the card in the recipient's transcript says the input came from. Part
10
- * of the run vocabulary for the same reason `runRef` is: the callback, the
11
- * subagent messages and the delegation prompt all name a run, and three
12
- * 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. */
13
8
  export const runSource = (run) => ({
14
9
  taskName: run.context.definition.name,
15
10
  ...(run.context.model ? { model: run.context.model } : {}),
@@ -47,15 +42,13 @@ export class TaskCallbacks {
47
42
  runId: runs[0].id,
48
43
  sourceSessionId: runs[0].targetSessionId,
49
44
  runIds: runs.map((run) => run.id),
50
- // Only when the input is about one run: a batch is several tasks in
51
- // one delivery, and the first one's name and model as the card's
52
- // caption would attribute every other result to it. The text names
53
- // 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.
54
47
  ...(runs.length === 1 ? { source: runSource(runs[0]), state: runs[0].state } : {}),
55
48
  },
56
49
  }),
57
- describe: (run) => `the result of "${run.context.definition.name}"`,
58
- }, unreachable);
50
+ abandoned: (run, sessionId, why) => unreachable(sessionId, `the result of "${run.context.definition.name}"`, why),
51
+ });
59
52
  }
60
53
  target(callback, origin) {
61
54
  if (callback.type === "session")
@@ -75,8 +68,7 @@ export class TaskCallbacks {
75
68
  if (!first?.callbackSessionId || (first.callbackState !== "pending" && first.callbackState !== "failed"))
76
69
  return;
77
70
  const sessionId = first.callbackSessionId;
78
- // Retry due-times are ignored when sweeping the batch: once one callback is
79
- // deliverable, everything pending for the session rides along.
71
+ // Once one callback is deliverable, everything pending for the session rides along.
80
72
  const batch = this.store.listPendingCallbacks(Number.MAX_SAFE_INTEGER)
81
73
  .filter((run) => run.callbackSessionId === sessionId);
82
74
  if (!batch.some((run) => run.id === first.id))
@@ -93,12 +93,8 @@ export function runBash(script, cwd, input, signal) {
93
93
  stderrTruncated: stderr.truncated,
94
94
  });
95
95
  });
96
- // A script that never reads stdin is ordinary (`exit 0`, a one-line curl),
97
- // and writing the input to a pipe nobody is holding raises EPIPE *here*.
98
- // Unhandled, that is an `error` event on a stream, which is an uncaught
99
- // exception, which is main.ts exiting the process: one task script could
100
- // take every session and every other run down with it. The input not being
101
- // 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.
102
98
  child.stdin.on("error", (err) => {
103
99
  if (err.code !== "EPIPE")
104
100
  log.warn(`run input could not be written: ${err.message}`);
@@ -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,7 +1,5 @@
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
4
  import { runBash } from "./command.js";
7
5
  const log = logger("tasks");
@@ -35,17 +33,27 @@ export class TaskExecution {
35
33
  this.controllers.set(run.id, controller);
36
34
  let timedOut = false;
37
35
  let cause;
38
- const timeout = setTimeout(() => {
39
- if (controller.signal.aborted)
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")
40
47
  return;
41
- timedOut = true;
42
- controller.abort();
43
- }, run.context.definition.timeoutSeconds * 1000);
44
- timeout.unref();
48
+ run.state = "running";
49
+ run.startedAt = Date.now();
50
+ this.store.saveRun(run);
51
+ this.host.changed(run);
52
+ };
45
53
  try {
46
54
  const { definition } = run.context;
47
55
  if (definition.trigger.type === "watch" && !run.resumedFromRunId) {
48
- this.markRunning(run);
56
+ start();
49
57
  run.probe = await runBash(definition.trigger.script, definition.trigger.cwd, run.input, controller.signal);
50
58
  run.matched = run.probe.exitCode === 0;
51
59
  this.store.saveRun(run);
@@ -55,20 +63,17 @@ export class TaskExecution {
55
63
  throw new Error(`watch probe exited ${String(run.probe.exitCode)}`);
56
64
  }
57
65
  if (run.matched !== false)
58
- run.result = await this.executeAction(run, controller.signal);
66
+ run.result = await this.executeAction(run, controller.signal, start);
59
67
  controller.signal.throwIfAborted();
60
68
  run.state = "succeeded";
61
69
  if (definition.trigger.type === "watch" && !run.resumedFromRunId && definition.trigger.mode === "once" && run.matched) {
62
- // As the definition's own creator: a one-shot watch retiring itself is
63
- // the task layer keeping its own promise, not a surface editing
64
- // somebody's task, and the owner guard (definitions.ts) would
65
- // 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.
66
72
  this.definitions.setEnabled(definition.id, false, definition.creator);
67
73
  }
68
74
  }
69
75
  catch (error) {
70
- // A killed child reports `exited null`, not `cancelled`: report why we
71
- // aborted instead of how the corpse looked.
76
+ // A killed child reports `exited null`; report why we aborted instead.
72
77
  const aborted = controller.signal.aborted;
73
78
  run.state = aborted ? (timedOut ? "failed" : "cancelled") : "failed";
74
79
  run.error = timedOut ? "task timed out" : aborted ? "cancelled" : String(error);
@@ -79,10 +84,8 @@ export class TaskExecution {
79
84
  run.finishedAt = Date.now();
80
85
  const seconds = ((run.finishedAt - (run.startedAt ?? run.queuedAt)) / 1000).toFixed(1);
81
86
  const settled = `run ${run.id} (${run.context.definition.name}) ${run.state} in ${seconds}s`;
82
- // A scheduled run has no one watching it: the run row is the only other
83
- // place this exists, and nobody opens the Console to find out it failed.
84
- // A watch probe that did not match is the opposite case — it fires on
85
- // 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.
86
89
  if (run.state === "failed")
87
90
  log.error(settled, cause);
88
91
  else if (run.matched === false)
@@ -98,23 +101,30 @@ export class TaskExecution {
98
101
  this.store.saveRun(run);
99
102
  }
100
103
  catch (err) {
101
- // Waiters settle from the in-memory run below; the callback and any
102
- // group join read the stale row and wait — the next boot's interrupt
103
- // sweep re-marks it and delivers then. Named here so that delay has
104
- // 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.
105
106
  log.error(`run ${run.id} final save failed — callback/join deferred to next boot`, err);
106
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
+ }
107
117
  this.host.changed(run);
108
118
  this.host.settled(run);
109
119
  if (run.callbackState === "pending")
110
120
  void this.callbacks.deliver(run);
111
121
  }
112
122
  }
113
- async executeAction(run, signal) {
123
+ async executeAction(run, signal, start) {
114
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; });
@@ -155,14 +165,6 @@ export class TaskExecution {
155
165
  signal.removeEventListener("abort", onAbort);
156
166
  }
157
167
  }
158
- return this.agent.execute(run, action, signal, () => this.markRunning(run));
159
- }
160
- markRunning(run) {
161
- if (run.state === "running")
162
- return;
163
- run.state = "running";
164
- run.startedAt = Date.now();
165
- this.store.saveRun(run);
166
- this.host.changed(run);
168
+ return this.agent.execute(run, action, signal, start);
167
169
  }
168
170
  }
@@ -1,7 +1,5 @@
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.
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.
5
3
  import { logger } from "../log.js";
6
4
  import { runRef, runResultText } from "./callbacks.js";
7
5
  import { newId } from "./definitions.js";
@@ -26,15 +24,14 @@ export class TaskGroups {
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. */
@@ -105,8 +102,8 @@ export class TaskGroups {
105
102
  evaluate(group) {
106
103
  if (group.finishedAt !== null)
107
104
  return;
108
- // Old versions could leave an empty group when admission failed. Retire
109
- // that broken record without inventing a successful zero-member join.
105
+ // An empty group is a broken record; retire it without inventing a
106
+ // successful zero-member join.
110
107
  if (group.memberRunIds.length === 0) {
111
108
  group.finishedAt = Date.now();
112
109
  group.callbackState = group.callbackSessionId ? "abandoned" : null;