@timqi/pier 0.0.29 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +24 -11
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +43 -62
  5. package/dist/agent/listing.js +113 -68
  6. package/dist/agent/pi.js +204 -211
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +8 -24
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +13 -35
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -260
  48. package/dist/core/types.js +17 -1
  49. package/dist/db.js +98 -252
  50. package/dist/drain.js +58 -51
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +6 -14
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +87 -179
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +19 -46
  65. package/dist/service.js +33 -75
  66. package/dist/settings.js +42 -65
  67. package/dist/tasks/agent.js +129 -114
  68. package/dist/tasks/callbacks.js +9 -19
  69. package/dist/tasks/command.js +29 -14
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +46 -42
  72. package/dist/tasks/groups.js +41 -35
  73. package/dist/tasks/messages.js +121 -182
  74. package/dist/tasks/outbox.js +61 -55
  75. package/dist/tasks/routes.js +5 -11
  76. package/dist/tasks/runs.js +14 -13
  77. package/dist/tasks/service.js +53 -54
  78. package/dist/tasks/store.js +53 -30
  79. package/dist/tasks/tool.js +132 -61
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +100 -327
  82. package/dist/update.js +21 -44
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +45 -85
  89. package/dist/web/providers.js +14 -13
  90. package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
  91. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  92. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  93. package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
  94. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  95. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
  97. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  98. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  99. package/dist/web/public/assets/index-DzXDXra_.js +85 -0
  100. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  101. package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/runs-Cwy0mN8i.js +1 -0
  106. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  108. package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
  109. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  110. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  111. package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
  112. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
  115. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
  117. package/dist/web/public/index.html +100 -130
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/manifest.webmanifest +2 -2
  121. package/dist/web/public/manifest.webmanifest.br +0 -0
  122. package/dist/web/public/manifest.webmanifest.gz +0 -0
  123. package/dist/web/public/sw.js +14 -2
  124. package/dist/web/public/sw.js.br +0 -0
  125. package/dist/web/public/sw.js.gz +0 -0
  126. package/dist/web/push.js +55 -77
  127. package/dist/web/route.js +3 -7
  128. package/dist/web/server.js +131 -180
  129. package/dist/web/session-state.js +14 -54
  130. package/dist/web/types.js +2 -4
  131. package/dist/web/webpush.js +10 -25
  132. package/docs/deploy.md +115 -330
  133. package/package.json +2 -1
  134. package/skills/pier-boards/SKILL.md +81 -160
  135. package/skills/pier-help/SKILL.md +23 -20
  136. package/skills/pier-slack/SKILL.md +2 -2
  137. package/skills/pier-tasks/SKILL.md +153 -160
  138. package/dist/config-sync-fetch.js +0 -84
  139. package/dist/limits.js +0 -14
  140. package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
  141. package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
  142. package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
  143. package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
  144. package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
  145. package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
  146. package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
  147. package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
  148. package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
  149. package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
  150. package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
  151. package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
  152. package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
  153. package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
  154. package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
  155. package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
  156. package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
  157. package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
  158. package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
  159. package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
  160. package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
  161. package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
  162. package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
  163. package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
  164. package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
  165. package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
@@ -1,7 +1,5 @@
1
- // The area's HTTP surface: tasks, runs, group and message routes for the
2
- // Console, plus the Activity snapshot it draws its graph from. A route reads
3
- // its body, names the caller and hands the decision to TaskService — policy
4
- // that lives here would be policy the task tool does not get.
1
+ // The area's HTTP surface. A route names the caller and hands the decision to
2
+ // TaskService: policy here would be policy the task tool does not get.
5
3
  import { record, requiredString } from "./definitions.js";
6
4
  /** Reject malformed filters instead of silently widening a global query. */
7
5
  function runQuery(params) {
@@ -51,10 +49,7 @@ export function registerTaskRoutes(app, tasks, activity) {
51
49
  const now = Date.now();
52
50
  const recent = c.req.query("scope") === "recent";
53
51
  const windowStart = now - 24 * 60 * 60 * 1000;
54
- // "recent" is a superset of "active": a run still going is part of the
55
- // last 24h no matter when it was queued, so it never drops a live run.
56
- const runs = tasks.recentRuns(200).filter((run) => run.state === "queued" || run.state === "running" ||
57
- (recent && run.queuedAt >= windowStart));
52
+ const runs = tasks.activityRuns(recent ? windowStart : undefined);
58
53
  const listed = await activity.factory.list();
59
54
  const byId = new Map(listed.map((session) => [session.id, session]));
60
55
  const linkedIds = new Set();
@@ -227,9 +222,8 @@ export function registerTaskRoutes(app, tasks, activity) {
227
222
  const body = record(await jsonBody(c.req));
228
223
  const input = body && "input" in body ? body.input : null;
229
224
  try {
230
- // A caller that names a mode gets an answer about it: dropping an
231
- // unsupported override would run the definition's own policy instead —
232
- // on a reuse definition, work injected into a live session.
225
+ // Dropping an unsupported override would run the definition's own policy
226
+ // instead — on a reuse definition, work injected into a live session.
233
227
  if (body?.sessionMode !== undefined && body.sessionMode !== "fresh") {
234
228
  throw new Error(`unsupported sessionMode: ${String(body.sessionMode)}`);
235
229
  }
@@ -1,12 +1,7 @@
1
- // A definition plus an input becomes a queued run: where it came from, how
2
- // deep in a subagent chain it sits, whether it overlaps a run already going,
3
- // and which session hears about it. The limits that keep a chain from
4
- // exploding (depth, children per root) are decided here, once, because every
5
- // caller — scheduler, tool, HTTP — enqueues through this one door.
1
+ // A definition plus an input becomes a queued run. The depth and per-root
2
+ // limits are decided here because every caller enqueues through this one door.
6
3
  import { logger } from "../log.js";
7
- import { TaskCallbacks } from "./callbacks.js";
8
4
  import { newId } from "./definitions.js";
9
- import { TaskStore } from "./store.js";
10
5
  const log = logger("tasks");
11
6
  const MAX_DEPTH = 2;
12
7
  const MAX_CHILDREN_PER_ROOT = 16;
@@ -23,7 +18,8 @@ export class TaskRunQueue {
23
18
  this.execute = execute;
24
19
  this.changed = changed;
25
20
  }
26
- enqueue(definition, input, source, parentRunId, provenance) {
21
+ /** Validate and persist only; callers may stage several runs in a transaction. */
22
+ prepare(definition, input, source, parentRunId, provenance) {
27
23
  const id = newId();
28
24
  const parent = parentRunId ? this.getRun(parentRunId) : null;
29
25
  const depth = provenance.depth ?? (parent ? parent.depth + 1 : 0);
@@ -63,6 +59,7 @@ export class TaskRunQueue {
63
59
  targetSessionId,
64
60
  sessionMode,
65
61
  callbackSessionId,
62
+ ...(provenance.callbackMode === "steer" ? { callbackMode: "steer" } : {}),
66
63
  background: provenance.background ?? false,
67
64
  callbackState: overlapped && callbackSessionId ? "pending" : null,
68
65
  callbackAttempts: 0,
@@ -84,11 +81,16 @@ export class TaskRunQueue {
84
81
  finishedAt: overlapped ? now : null,
85
82
  };
86
83
  this.store.saveRun(run);
84
+ return run;
85
+ }
86
+ /** Publish and execute only after all records this run relies on committed. */
87
+ start(run) {
87
88
  this.changed(run);
88
- // Why a run exists is the first question asked of a surprising one, and it
89
- // is answerable only here: the row keeps the ids, not the reason. A watch
90
- // probe queues on every interval and mostly matches nothing, so it says so
91
- // at debug and lets its settled line (execution.ts) carry the news.
89
+ const { id, depth, triggerSource: source } = run;
90
+ const overlapped = run.skipReason === "overlap";
91
+ const definition = run.context.definition;
92
+ // Why a run exists is answerable only here: the row keeps the ids, not the
93
+ // reason. A watch probe queues every interval, so it logs at debug.
92
94
  const queued = `run ${id} ${run.state}: ${definition.name} via ${source}` +
93
95
  `${overlapped ? " (overlapped)" : ""}${depth > 0 ? ` depth ${String(depth)}` : ""}`;
94
96
  if (source === "watch" && !overlapped)
@@ -99,6 +101,5 @@ export class TaskRunQueue {
99
101
  this.execute(run);
100
102
  else if (run.callbackState === "pending")
101
103
  void this.callbacks.deliver(run);
102
- return run;
103
104
  }
104
105
  }
@@ -1,11 +1,6 @@
1
- // The one object the rest of Pier talks to about tasks, and the clock behind
2
- // it: the tick that finds what is due, the boot recovery that writes off runs
3
- // a restart interrupted, and the pause a drain needs. Every decision it looks
4
- // like it makes belongs to a file beside it (definitions, runs, execution,
5
- // groups, messages, callbacks) — what is genuinely here is scheduling and the
6
- // facade, so the HTTP routes and the task tool cannot drift apart.
7
- import { EventHub } from "../core/hub.js";
8
- import { Router } from "../core/router.js";
1
+ // The facade the rest of Pier talks to about tasks, and the clock behind it:
2
+ // the tick, the boot recovery that writes off interrupted runs, and the pause a
3
+ // drain needs. Decisions belong to the files beside it.
9
4
  import { logger } from "../log.js";
10
5
  import { AgentTaskRunner } from "./agent.js";
11
6
  import { TaskCallbacks } from "./callbacks.js";
@@ -14,13 +9,11 @@ import { TaskExecution } from "./execution.js";
14
9
  import { TaskGroups } from "./groups.js";
15
10
  import { TaskMessenger } from "./messages.js";
16
11
  import { TaskRunQueue } from "./runs.js";
17
- import { TaskStore } from "./store.js";
18
12
  import { handleTaskTool } from "./tool.js";
19
13
  import { isTerminal } from "./types.js";
20
14
  const log = logger("tasks");
21
- /** The text a run was given, as the operator wrote it: a resume's follow-up,
22
- * else the action's own prompt or script. Not `renderedPrompt` — that is the
23
- * preamble and the input wrapper, which the delegating session did not send. */
15
+ /** Not `renderedPrompt`: that carries the preamble and input wrapper, which the
16
+ * delegating session did not send. */
24
17
  const runPrompt = (run) => {
25
18
  if (run.context.resumePrompt)
26
19
  return run.context.resumePrompt;
@@ -43,8 +36,7 @@ export class TaskService {
43
36
  runs;
44
37
  execution;
45
38
  constructor(store, factory, router, hub,
46
- /** Structural on purpose: tasks/ must not import settings.ts — main.ts
47
- * hands in a closure over the store instead. Absent in bare test rigs. */
39
+ /** Structural: tasks/ must not import settings.ts. Absent in bare test rigs. */
48
40
  instance) {
49
41
  this.store = store;
50
42
  this.factory = factory;
@@ -52,20 +44,21 @@ export class TaskService {
52
44
  this.hub = hub;
53
45
  this.instance = instance;
54
46
  const unreachable = (sessionId, what, why) => this.unreachable(sessionId, what, why);
55
- this.messages = new TaskMessenger(store, router, hub, (runId, prompt, fromSessionId) => this.resume(runId, prompt, { invokedBySessionId: fromSessionId, callbackSessionId: fromSessionId, background: true }), unreachable);
47
+ this.messages = new TaskMessenger(store, router, hub, (runId, prompt, fromSessionId) => this.prepareResume(runId, prompt, { invokedBySessionId: fromSessionId, callbackSessionId: fromSessionId, background: true }), unreachable, (run) => this.runs.start(run));
56
48
  this.definitions = new TaskDefinitions(store, factory, router, hub, instance?.systemActions);
57
49
  this.callbacks = new TaskCallbacks(store, router, (run) => this.changed(run), unreachable);
58
50
  this.groups = new TaskGroups(store, router, {
59
51
  getRun: (id) => this.getRun(id),
60
52
  cancel: (id) => { this.cancel(id); },
61
53
  openDecisionId: (runId) => this.messages.openDecisionId(runId),
62
- startMember: (taskId, groupId, callerSessionId, parentRunId) => this.run(taskId, null, "agent", parentRunId, {
54
+ prepareMember: (taskId, groupId, callerSessionId, parentRunId) => this.prepareRun(taskId, null, "agent", parentRunId, {
63
55
  invokedBySessionId: callerSessionId,
64
56
  sourceSessionId: callerSessionId,
65
57
  callbackSessionId: null,
66
58
  background: true,
67
59
  groupId,
68
60
  }),
61
+ startMember: (run) => this.runs.start(run),
69
62
  }, (group) => this.hub.emitWorkspace({ type: "task-group-changed", groupId: group.id }), unreachable);
70
63
  const agent = new AgentTaskRunner(factory, router, store, this.messages, (run) => this.changed(run));
71
64
  this.execution = new TaskExecution(store, this.definitions, this.callbacks, agent, {
@@ -110,10 +103,8 @@ export class TaskService {
110
103
  }, tickMs);
111
104
  this.timer.unref();
112
105
  }
113
- /** Undo a `pause()` that was not followed by an exit — the auto-updater
114
- * drains before handing over, and a handover that never started must not
115
- * leave the scheduler switched off. Deliberately not `start()`: the boot
116
- * recovery in there would write off runs this process is still running. */
106
+ /** For a handover that never started. Not `start()`: its boot recovery would
107
+ * write off runs this process is still running. */
117
108
  unpause(tickMs = 1000) {
118
109
  if (this.timer)
119
110
  return;
@@ -124,11 +115,8 @@ export class TaskService {
124
115
  this.pause();
125
116
  this.execution.stop();
126
117
  }
127
- /** Stop taking new work but leave running runs alone — a graceful restart
128
- * (src/drain.ts) waits for them, where stop() would abort them. The
129
- * scheduler timer goes, and new root runs are refused; children of a run
130
- * that is still finishing stay allowed, because refusing them would fail
131
- * the very work the drain is waiting for. */
118
+ /** New root runs are refused, running ones left for the drain to wait on;
119
+ * children of a finishing run stay allowed, or the drain fails its own work. */
132
120
  pause() {
133
121
  this.paused = true;
134
122
  if (this.timer)
@@ -145,11 +133,8 @@ export class TaskService {
145
133
  activeRunCount() {
146
134
  return this.store.countActiveRuns();
147
135
  }
148
- /** The run this task has in flight, if any. The store already answers this
149
- * for the overlap guard (runs.ts); a caller that has just been refused as
150
- * an overlap needs the same answer to know what to wait for, and scanning
151
- * run history for it finds nothing once the skipped rows outnumber the
152
- * window. */
136
+ /** A caller refused as an overlap needs this to know what to wait for;
137
+ * scanning run history finds nothing once skipped rows outnumber the window. */
153
138
  activeRun(taskId) {
154
139
  return this.store.findActiveRun(taskId);
155
140
  }
@@ -162,8 +147,7 @@ export class TaskService {
162
147
  create(raw, creator = "http") {
163
148
  return this.definitions.create(raw, creator);
164
149
  }
165
- /** `by` is how the code that owns a definition says so; the HTTP routes and
166
- * the task tool have none, which is what closes both (definitions.ts). */
150
+ /** `by` is how owning code says so; the routes and the tool have none (definitions.ts). */
167
151
  update(id, raw, by) {
168
152
  return this.definitions.update(id, raw, by);
169
153
  }
@@ -198,22 +182,19 @@ export class TaskService {
198
182
  queryRuns(query = {}) {
199
183
  return this.store.queryRuns(query);
200
184
  }
201
- recentRuns(limit = 100) {
202
- return this.store.listRecentRuns(limit);
185
+ activityRuns(since) {
186
+ return this.store.activityRuns(since);
203
187
  }
204
188
  recentMessages(since) {
205
189
  return this.messages.recent(since);
206
190
  }
207
- /** Every run this session delegated: the card is the message that started
208
- * it, and a message does not leave the transcript because the run ended. */
191
+ /** Every run, not the last hour's: the card is a message in the transcript. */
209
192
  backgroundRuns(sessionId) {
210
193
  return this.store.listRunsForSession(sessionId, 200)
211
194
  .filter((run) => run.background)
212
195
  .reverse()
213
196
  .map((run) => this.backgroundRun(run));
214
197
  }
215
- /** The same runs `backgroundRuns` reports as in flight, counted per session
216
- * in one query — a list needs the number, not the runs. */
217
198
  activeBackgroundRunCounts() {
218
199
  return this.store.countActiveBackgroundRunsBySession();
219
200
  }
@@ -222,13 +203,18 @@ export class TaskService {
222
203
  return this.store.taskOwnedSessionIds();
223
204
  }
224
205
  run(taskId, input = null, source = "manual", parentRunId = null, provenance = {}) {
206
+ const run = this.prepareRun(taskId, input, source, parentRunId, provenance);
207
+ this.runs.start(run);
208
+ return run;
209
+ }
210
+ prepareRun(taskId, input, source, parentRunId, provenance) {
225
211
  this.refusePaused(parentRunId);
226
212
  const task = this.get(taskId);
227
213
  // `enabled:false` pauses scheduling only; manual and agent triggers still
228
214
  // run a paused task on demand. Archiving is the terminal state.
229
215
  if (task.archived)
230
216
  throw new Error("archived tasks cannot run");
231
- return this.runs.enqueue(task, input, source, parentRunId, provenance);
217
+ return this.runs.prepare(task, input, source, parentRunId, provenance);
232
218
  }
233
219
  async waitForRun(id) {
234
220
  const current = this.getRun(id);
@@ -256,9 +242,9 @@ export class TaskService {
256
242
  getGroup(id) {
257
243
  return this.groups.members(id);
258
244
  }
259
- runGroup(definitions, join, callerSessionId, parentRunId, callbackSessionId) {
245
+ runGroup(definitions, join, callerSessionId, parentRunId, callbackSessionId, callbackMode) {
260
246
  this.refusePaused(parentRunId);
261
- return this.groups.runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId);
247
+ return this.groups.runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId, callbackMode);
262
248
  }
263
249
  descendants(run) {
264
250
  const byParent = new Map();
@@ -291,6 +277,16 @@ export class TaskService {
291
277
  return this.messages.reply(messageId, fromSessionId, message);
292
278
  }
293
279
  resume(id, message, provenance = {}) {
280
+ const { run, expired } = this.store.transact(() => ({
281
+ run: this.prepareResume(id, message, provenance),
282
+ expired: this.messages.expireDecisions(id, "superseded by a manual resume"),
283
+ }));
284
+ for (const message of expired)
285
+ this.messages.changed(message);
286
+ this.runs.start(run);
287
+ return run;
288
+ }
289
+ prepareResume(id, message, provenance) {
294
290
  this.refusePaused();
295
291
  const prior = this.getRun(id);
296
292
  if (!isTerminal(prior.state))
@@ -299,8 +295,7 @@ export class TaskService {
299
295
  throw new Error("only persisted Agent runs can be resumed");
300
296
  }
301
297
  const prompt = requiredString(message, "message");
302
- this.messages.expireDecisions(prior.id, "superseded by a manual resume");
303
- return this.runs.enqueue(prior.context.definition, null, "agent", null, {
298
+ return this.runs.prepare(prior.context.definition, null, "agent", null, {
304
299
  ...provenance,
305
300
  sourceSessionId: provenance.invokedBySessionId ?? prior.invokedBySessionId,
306
301
  targetSessionId: prior.targetSessionId,
@@ -314,9 +309,7 @@ export class TaskService {
314
309
  tool(raw, callerSessionId) {
315
310
  return handleTaskTool(this, this.definitions, this.store, this.messages, raw, callerSessionId);
316
311
  }
317
- /** The deployment's model advice: the operator's pinned menu when one is
318
- * set, the curated live catalog otherwise — an agent picks from names that
319
- * exist right now, never from memory. */
312
+ /** An agent picks from names that exist right now, never from memory. */
320
313
  async models() {
321
314
  const menu = this.instance?.modelMenu() ?? [];
322
315
  if (menu.length)
@@ -330,8 +323,17 @@ export class TaskService {
330
323
  try {
331
324
  const now = Date.now();
332
325
  this.sweep("schedule", () => {
333
- for (const task of this.definitions.claimDue(now)) {
334
- this.run(task.id, null, task.trigger.type === "watch" ? "watch" : "cron");
326
+ // Per task: one enqueue that throws must not spend the other due tasks'
327
+ // occurrence, and its own stays due for the next tick.
328
+ for (const task of this.definitions.due(now)) {
329
+ this.sweep(`schedule ${task.name}`, () => {
330
+ const run = this.store.transact(() => {
331
+ this.definitions.advance(task, now);
332
+ return this.prepareRun(task.id, null, task.trigger.type === "watch" ? "watch" : "cron", null, {});
333
+ });
334
+ this.hub.emitWorkspace({ type: "tasks-changed" });
335
+ this.runs.start(run);
336
+ });
335
337
  }
336
338
  });
337
339
  this.sweep("run callbacks", () => this.callbacks.recover(now));
@@ -342,16 +344,13 @@ export class TaskService {
342
344
  this.ticking = false;
343
345
  }
344
346
  }
345
- /** A delivery nobody can complete. Retrying it forever costs the same
346
- * silence as dropping it, so it stops here and says so on three surfaces:
347
- * the operator's log, the record the tool and Console read, and the event
348
- * stream of the session that was supposed to receive it (§5b). */
347
+ /** Retrying forever costs the same silence as dropping, so it stops and says
348
+ * so on the log, the record and the recipient's event stream (§5). */
349
349
  unreachable(sessionId, what, why) {
350
350
  log.error(`gave up delivering ${what} to session ${sessionId}: ${why}`);
351
351
  this.router.reportTo(sessionId, `${what} could not be delivered — ${why}`);
352
352
  }
353
- /** Four independent sweeps, isolated: one throwing (a group whose member row
354
- * is gone throws on every pass) must not starve the retries behind it. */
353
+ /** Isolated: one sweep throwing on every pass must not starve the others. */
355
354
  sweep(what, run) {
356
355
  try {
357
356
  run();
@@ -1,9 +1,10 @@
1
- // Every query this area makes against pier.db, and nothing else: definitions,
2
- // runs, groups and messages are rows here, read and written through one
3
- // connection db.ts opened. A store owns its queries, never its own tables or
4
- // its own handle — the schema is db.ts's migration list.
5
- import { pierDb, statements } from "../db.js";
1
+ // Every query this area makes against pier.db, and nothing else; the schema is
2
+ // db.ts's migration list.
3
+ import { pierDb, statements, transact } from "../db.js";
6
4
  const clamp = (limit, cap) => Math.min(Math.max(limit, 1), cap);
5
+ /** Kept unmatched probes per watch: one page of history, which is all any
6
+ * surface lists of a probe that found nothing. */
7
+ const KEPT_PROBES = 50;
7
8
  export class TaskStore {
8
9
  db;
9
10
  /** Every query below is a fixed string, so each is compiled once. */
@@ -12,9 +13,11 @@ export class TaskStore {
12
13
  this.db = db;
13
14
  this.sql = statements(db);
14
15
  }
15
- // Every table is one JSON column plus query columns; these two are the only
16
- // readers, and the one seam where a future schema change normalizes old rows
17
- // (pre-v1 databases are refused outright in db.ts).
16
+ /** Commit related task records before publishing events or starting work. */
17
+ transact(work) {
18
+ return transact(this.db, work);
19
+ }
20
+ // The only readers of the JSON columns, and where a schema change normalizes old rows.
18
21
  #one(sql, ...params) {
19
22
  const row = this.sql(sql).get(...params);
20
23
  return row ? JSON.parse(row.json) : undefined;
@@ -35,9 +38,7 @@ export class TaskStore {
35
38
  ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at, json=excluded.json
36
39
  `).run(task.id, task.updatedAt, JSON.stringify(task));
37
40
  }
38
- /** The tick's whole question, asked of the index rather than of every
39
- * document: a task with no next run (disabled, archived, manual) is not in
40
- * it, so an idle second reads no row. */
41
+ /** Asked of the index, not every document: an idle second reads no row. */
41
42
  listDueTasks(now) {
42
43
  return this.#many("SELECT json FROM tasks WHERE next_run_at IS NOT NULL AND next_run_at <= ?", now);
43
44
  }
@@ -58,9 +59,28 @@ export class TaskStore {
58
59
  state=excluded.state, callback_state=excluded.callback_state, json=excluded.json
59
60
  `).run(run.id, run.taskId, run.queuedAt, run.state, run.callbackState, JSON.stringify(run));
60
61
  }
61
- /** The global list: filters precede the limit, and the id breaks timestamp
62
- * ties so a page boundary never repeats or skips a row. The statement is
63
- * built per call and not cached — the filter set makes it. */
62
+ /** A watch at the five-second floor mints 17k rows a day, ~1.6 kB each, that
63
+ * no list shows; only rows nothing can dangle from are dropped — a child
64
+ * run is named in its parent's stored result. */
65
+ pruneUnmatchedProbes(taskId) {
66
+ this.sql(`
67
+ DELETE FROM task_runs WHERE id IN (
68
+ SELECT r.id FROM task_runs r
69
+ WHERE r.task_id = ? AND r.state = 'succeeded' AND json_extract(r.json, '$.matched') IS 0
70
+ AND r.callback_state IS NULL AND json_extract(r.json, '$.groupId') IS NULL
71
+ AND json_extract(r.json, '$.parentRunId') IS NULL
72
+ AND NOT EXISTS (SELECT 1 FROM task_messages m WHERE m.run_id = r.id)
73
+ -- A NULL in this list would make NOT IN unknown for every candidate.
74
+ AND r.id NOT IN (
75
+ SELECT json_extract(c.json, '$.resumedFromRunId') FROM task_runs c
76
+ WHERE c.task_id = ? AND json_extract(c.json, '$.resumedFromRunId') IS NOT NULL
77
+ )
78
+ ORDER BY r.queued_at DESC, r.id DESC LIMIT -1 OFFSET ?
79
+ )
80
+ `).run(taskId, taskId, KEPT_PROBES);
81
+ }
82
+ /** The id breaks timestamp ties so a page boundary never repeats or skips a
83
+ * row. Built per call, not cached: the filter set makes it. */
64
84
  queryRuns(query = {}) {
65
85
  const where = [];
66
86
  const params = [];
@@ -101,8 +121,18 @@ export class TaskStore {
101
121
  nextCursor: rows.length > limit && last ? { queuedAt: last.queuedAt, id: last.id } : null,
102
122
  };
103
123
  }
104
- listRecentRuns(limit = 100) {
105
- return this.#many("SELECT json FROM task_runs ORDER BY queued_at DESC LIMIT ?", clamp(limit, 500));
124
+ /** Activity never limits live work; only its optional history is bounded. */
125
+ activityRuns(since) {
126
+ const active = this.#many("SELECT json FROM task_runs WHERE state IN ('queued', 'running') ORDER BY queued_at DESC, id DESC");
127
+ if (since === undefined)
128
+ return active;
129
+ const recent = this.#many(`
130
+ SELECT json FROM task_runs
131
+ WHERE state NOT IN ('queued', 'running') AND queued_at >= ?
132
+ AND NOT (state = 'succeeded' AND json_extract(json, '$.matched') IS 0)
133
+ ORDER BY queued_at DESC, id DESC LIMIT 200
134
+ `, since);
135
+ return [...active, ...recent].sort((a, b) => b.queuedAt - a.queuedAt || b.id.localeCompare(a.id));
106
136
  }
107
137
  listRunsByRoot(rootRunId, limit = 100) {
108
138
  return this.#many(`
@@ -129,9 +159,7 @@ export class TaskStore {
129
159
  ORDER BY CASE state WHEN 'running' THEN 0 ELSE 1 END, queued_at DESC LIMIT 1
130
160
  `, sessionId);
131
161
  }
132
- /** How many background runs each session has in flight, for a list that
133
- * draws one dot per row: the state column narrows the scan to the handful
134
- * of live runs, and no row's JSON is parsed. */
162
+ /** The state column narrows the scan to live runs; no JSON is parsed. */
135
163
  countActiveBackgroundRunsBySession() {
136
164
  const rows = this.sql(`
137
165
  SELECT json_extract(json, '$.invokedBySessionId') AS session_id, COUNT(*) AS n
@@ -143,10 +171,8 @@ export class TaskStore {
143
171
  `).all();
144
172
  return new Map(rows.map((row) => [row.session_id, row.n]));
145
173
  }
146
- /** Every session a run created for itself — `fresh` is the one mode that
147
- * makes a session rather than borrowing one, and a resumed run reuses the
148
- * session its `fresh` predecessor already stamped. These are the agent's
149
- * conversations with itself, which the rail does not list. */
174
+ /** `fresh` is the one mode that makes a session rather than borrowing one;
175
+ * these are the agent's conversations with itself, which the rail does not list. */
150
176
  taskOwnedSessionIds() {
151
177
  const rows = this.sql(`
152
178
  SELECT DISTINCT json_extract(json, '$.context.sessionId') AS id
@@ -200,10 +226,8 @@ export class TaskStore {
200
226
  listRecentMessages(since) {
201
227
  return this.#many("SELECT json FROM task_messages WHERE created_at >= ? ORDER BY created_at DESC LIMIT 200", since);
202
228
  }
203
- /** Messages whose injection never landed, each beside the run it belongs to:
204
- * the delivery sweep needs both, and fetching the run per message made one
205
- * sweep cost a query per undelivered message. A message whose run is gone
206
- * comes back with `run: undefined` — the sweep drops it. */
229
+ /** Each beside its run, so the sweep costs one query. A message whose run is
230
+ * gone comes back with `run: undefined`. */
207
231
  listUndeliveredMessages() {
208
232
  const rows = this.sql(`
209
233
  SELECT m.json AS json, r.json AS run_json
@@ -223,9 +247,8 @@ export class TaskStore {
223
247
  WHERE state = 'pending' AND json_extract(json, '$.kind') != 'decision'
224
248
  `).map((message) => {
225
249
  message.state = "expired";
226
- // "Confirmed", not "completed": the input may well have been read — the
227
- // proof of it lives in the recipient's transcript, which this layer
228
- // cannot see, and the run it steered is interrupted by the same restart.
250
+ // "Confirmed", not "completed": the proof lives in a transcript this
251
+ // layer cannot see.
229
252
  message.error = "Pier restarted before delivery could be confirmed";
230
253
  this.saveMessage(message);
231
254
  return message;