@timqi/pier 0.0.29 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/agent/config.js +22 -0
  2. package/dist/agent/events.js +4 -1
  3. package/dist/agent/listing.js +114 -16
  4. package/dist/agent/pi.js +158 -16
  5. package/dist/channels/lark-panel.js +1 -1
  6. package/dist/core/inbox.js +1 -1
  7. package/dist/core/router.js +4 -0
  8. package/dist/core/types.js +13 -1
  9. package/dist/db.js +30 -0
  10. package/dist/drain.js +1 -1
  11. package/dist/extensions/web/content.js +2 -2
  12. package/dist/main.js +8 -1
  13. package/dist/secrets.js +1 -1
  14. package/dist/service.js +2 -2
  15. package/dist/settings.js +31 -10
  16. package/dist/tasks/agent.js +109 -73
  17. package/dist/tasks/callbacks.js +1 -3
  18. package/dist/tasks/command.js +27 -8
  19. package/dist/tasks/execution.js +6 -4
  20. package/dist/tasks/groups.js +35 -26
  21. package/dist/tasks/messages.js +33 -27
  22. package/dist/tasks/outbox.js +48 -21
  23. package/dist/tasks/routes.js +1 -4
  24. package/dist/tasks/runs.js +10 -4
  25. package/dist/tasks/service.js +24 -12
  26. package/dist/tasks/store.js +17 -3
  27. package/dist/tasks/tool.js +97 -25
  28. package/dist/tools.js +2 -2
  29. package/dist/update.js +1 -1
  30. package/dist/web/instance.js +10 -3
  31. package/dist/web/providers.js +9 -2
  32. package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-Bl3vZukb.js} +2 -2
  33. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  34. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  35. package/dist/web/public/assets/boards-DYuf4Mlj.js +1 -0
  36. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  37. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  38. package/dist/web/public/assets/explorer-qJH_9nTE.js +4 -0
  39. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  40. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  41. package/dist/web/public/assets/index-Dqdb-Eqt.js +85 -0
  42. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  43. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  44. package/dist/web/public/assets/index-DzmMzvi_.css +2 -0
  45. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  46. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  47. package/dist/web/public/assets/runs-BLJu7EXN.js +1 -0
  48. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  49. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  50. package/dist/web/public/assets/settings-BrdVh-Zi.js +5 -0
  51. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  52. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  53. package/dist/web/public/assets/task-runs-CeQS1rxa.js +3 -0
  54. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  55. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  56. package/dist/web/public/assets/tasks-bcb3fYdK.js +4 -0
  57. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  58. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
  59. package/dist/web/public/index.html +76 -120
  60. package/dist/web/public/index.html.br +0 -0
  61. package/dist/web/public/index.html.gz +0 -0
  62. package/dist/web/public/manifest.webmanifest +2 -2
  63. package/dist/web/public/manifest.webmanifest.br +0 -0
  64. package/dist/web/public/manifest.webmanifest.gz +0 -0
  65. package/dist/web/server.js +40 -8
  66. package/dist/web/session-state.js +5 -5
  67. package/package.json +2 -1
  68. package/skills/pier-tasks/SKILL.md +145 -160
  69. package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
  70. package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
  71. package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
  72. package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
  73. package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
  74. package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
  75. package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
  76. package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
  77. package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
  78. package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
  79. package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
  80. package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
  81. package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
  82. package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
  83. package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
  84. package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
  85. package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
  86. package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
  87. package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
  88. package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
  89. package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
  90. package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
  91. package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
  92. package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
  93. package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
  94. package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
@@ -2,12 +2,10 @@
2
2
  // separate endings into one answer — when the join condition is met, which
3
3
  // members are cancelled, and the single aggregated callback that goes back.
4
4
  // Delivering it is the outbox's job; deciding it is this file's.
5
- import { Router } from "../core/router.js";
6
5
  import { logger } from "../log.js";
7
6
  import { runRef, runResultText } from "./callbacks.js";
8
7
  import { newId } from "./definitions.js";
9
8
  import { Outbox } from "./outbox.js";
10
- import { TaskStore } from "./store.js";
11
9
  import { isTerminal } from "./types.js";
12
10
  const log = logger("tasks");
13
11
  /** Core-owned fan-out join: members run detached, the group delivers one
@@ -16,11 +14,13 @@ export class TaskGroups {
16
14
  store;
17
15
  host;
18
16
  changed;
17
+ unreachable;
19
18
  outbox;
20
19
  constructor(store, router, host, changed, unreachable) {
21
20
  this.store = store;
22
21
  this.host = host;
23
22
  this.changed = changed;
23
+ this.unreachable = unreachable;
24
24
  this.outbox = new Outbox(router, {
25
25
  id: (group) => group.id,
26
26
  reload: (id) => this.store.getGroup(id),
@@ -38,21 +38,21 @@ export class TaskGroups {
38
38
  }
39
39
  /** Enqueues every member or none: a partially started group is worse than
40
40
  * a rejected one. */
41
- runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId) {
42
- const group = this.create(join, callerSessionId, callbackSessionId);
43
- const runs = [];
44
- try {
45
- for (const definition of definitions) {
46
- runs.push(this.host.startMember(definition.id, group.id, callerSessionId, parentRunId));
47
- }
48
- }
49
- catch (error) {
50
- log.warn(`group ${group.id} rolled back after ${String(runs.length)} members`, error);
51
- for (const run of runs)
52
- this.host.cancel(run.id);
53
- throw error;
54
- }
55
- this.setMembers(group, runs.map((run) => run.id));
41
+ runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId, callbackMode) {
42
+ if (definitions.length < 2)
43
+ throw new Error("a task group needs at least 2 members");
44
+ const { group, runs } = this.store.transact(() => {
45
+ const group = this.create(join, callerSessionId, callbackSessionId, callbackMode);
46
+ const runs = definitions.map((definition) => this.host.prepareMember(definition.id, group.id, callerSessionId, parentRunId));
47
+ group.memberRunIds = runs.map((run) => run.id);
48
+ this.store.saveGroup(group);
49
+ return { group, runs };
50
+ });
51
+ this.changed(group);
52
+ for (const run of runs)
53
+ this.host.startMember(run);
54
+ // Skipped members do not pass through execution's settled hook.
55
+ this.evaluate(this.get(group.id));
56
56
  return { group: this.get(group.id), runs };
57
57
  }
58
58
  members(id) {
@@ -64,12 +64,13 @@ export class TaskGroups {
64
64
  this.host.cancel(runId);
65
65
  return this.get(id);
66
66
  }
67
- create(join, invokedBySessionId, callbackSessionId) {
68
- const group = {
67
+ create(join, invokedBySessionId, callbackSessionId, callbackMode) {
68
+ return {
69
69
  id: newId(),
70
70
  join,
71
71
  invokedBySessionId,
72
72
  callbackSessionId,
73
+ ...(callbackMode === "steer" ? { callbackMode } : {}),
73
74
  memberRunIds: [],
74
75
  winnerRunId: null,
75
76
  callbackState: null,
@@ -79,13 +80,6 @@ export class TaskGroups {
79
80
  createdAt: Date.now(),
80
81
  finishedAt: null,
81
82
  };
82
- this.store.saveGroup(group);
83
- return group;
84
- }
85
- setMembers(group, runIds) {
86
- group.memberRunIds = runIds;
87
- this.store.saveGroup(group);
88
- this.changed(group);
89
83
  }
90
84
  get(id) {
91
85
  const group = this.store.getGroup(id);
@@ -109,6 +103,21 @@ export class TaskGroups {
109
103
  }
110
104
  }
111
105
  evaluate(group) {
106
+ if (group.finishedAt !== null)
107
+ return;
108
+ // Old versions could leave an empty group when admission failed. Retire
109
+ // that broken record without inventing a successful zero-member join.
110
+ if (group.memberRunIds.length === 0) {
111
+ group.finishedAt = Date.now();
112
+ group.callbackState = group.callbackSessionId ? "abandoned" : null;
113
+ group.callbackError = "group admission failed before its members were recorded";
114
+ this.store.saveGroup(group);
115
+ this.changed(group);
116
+ log.error(`group ${group.id}: ${group.callbackError}`);
117
+ if (group.callbackSessionId)
118
+ this.unreachable(group.callbackSessionId, `task group ${group.id}`, group.callbackError);
119
+ return;
120
+ }
112
121
  const members = group.memberRunIds.map((id) => this.host.getRun(id));
113
122
  if (group.join === "first") {
114
123
  const winner = members.find((run) => isTerminal(run.state));
@@ -4,12 +4,9 @@
4
4
  // the two ends are different sessions and either may be mid-turn, gone, or
5
5
  // finished — an undelivered message is retried, expired and *said*, never
6
6
  // dropped (§5b).
7
- import { EventHub } from "../core/hub.js";
8
- import { Router } from "../core/router.js";
9
7
  import { logger } from "../log.js";
10
8
  import { runSource } from "./callbacks.js";
11
9
  import { newId } from "./definitions.js";
12
- import { TaskStore } from "./store.js";
13
10
  import { isTerminal, MAX_DELIVERY_ATTEMPTS, retryDelay, undeliverable } from "./types.js";
14
11
  const log = logger("tasks");
15
12
  const MAX_MESSAGE_LENGTH = 16 * 1024;
@@ -25,18 +22,20 @@ export class TaskMessenger {
25
22
  store;
26
23
  router;
27
24
  hub;
28
- resumeRun;
25
+ prepareResume;
29
26
  unreachable;
27
+ startRun;
30
28
  constructor(store, router, hub,
31
- /** Continues a terminal child with a supervisor reply as its prompt. */
32
- resumeRun,
29
+ /** Prepares a continuation; it starts only after the reply commits. */
30
+ prepareResume,
33
31
  /** Reports a delivery nobody can complete (service.ts owns the surfaces). */
34
- unreachable) {
32
+ unreachable, startRun) {
35
33
  this.store = store;
36
34
  this.router = router;
37
35
  this.hub = hub;
38
- this.resumeRun = resumeRun;
36
+ this.prepareResume = prepareResume;
39
37
  this.unreachable = unreachable;
38
+ this.startRun = startRun;
40
39
  }
41
40
  expirePending() {
42
41
  for (const message of this.store.expirePendingMessages())
@@ -49,6 +48,7 @@ export class TaskMessenger {
49
48
  /** A manual continuation supersedes an unanswered decision (design 04):
50
49
  * one continuation per run, never two racing ones. */
51
50
  expireDecisions(runId, reason) {
51
+ const expired = [];
52
52
  for (const message of this.store.listMessages(runId)) {
53
53
  if (message.kind !== "decision" || (message.state !== "pending" && message.state !== "delivered"))
54
54
  continue;
@@ -56,8 +56,9 @@ export class TaskMessenger {
56
56
  message.error = reason;
57
57
  message.answeredAt = Date.now();
58
58
  this.store.saveMessage(message);
59
- this.changed(message);
59
+ expired.push(message);
60
60
  }
61
+ return expired;
61
62
  }
62
63
  list(runId) {
63
64
  return this.store.listMessages(runId);
@@ -67,6 +68,7 @@ export class TaskMessenger {
67
68
  }
68
69
  async control(run, fromSessionId, kind, content) {
69
70
  const message = this.create(run, kind, fromSessionId, run.targetSessionId ?? "", content, null);
71
+ this.changed(message);
70
72
  if (run.targetSessionId)
71
73
  this.deliver(message, run, run.targetSessionId);
72
74
  return this.require(message.id);
@@ -139,6 +141,7 @@ export class TaskMessenger {
139
141
  throw new Error("run already has a pending supervisor decision");
140
142
  }
141
143
  const message = this.create(run, reason, fromSessionId, run.invokedBySessionId, content, null);
144
+ this.changed(message);
142
145
  this.deliver(message, run, run.invokedBySessionId);
143
146
  return this.require(message.id);
144
147
  }
@@ -161,25 +164,28 @@ export class TaskMessenger {
161
164
  const run = this.store.getRun(question.runId);
162
165
  if (!run)
163
166
  throw new Error(`unknown task run: ${question.runId}`);
164
- const reply = this.create(run, "reply", fromSessionId, question.fromSessionId, text, question.id);
165
- question.state = "answered";
166
- question.answeredAt = Date.now();
167
- this.store.saveMessage(question);
167
+ // A rejected continuation must leave the question answerable. Persist the
168
+ // answer and its queued run together, before either publishes or executes.
169
+ const { reply, continuation } = this.store.transact(() => {
170
+ const reply = this.create(run, "reply", fromSessionId, question.fromSessionId, text, question.id);
171
+ question.state = "answered";
172
+ question.answeredAt = Date.now();
173
+ this.store.saveMessage(question);
174
+ const continuation = isTerminal(run.state)
175
+ ? this.prepareResume(run.id, this.format(reply, run), fromSessionId)
176
+ : undefined;
177
+ if (continuation) {
178
+ reply.resumeRunId = continuation.id;
179
+ this.store.saveMessage(reply);
180
+ }
181
+ return { reply, continuation };
182
+ });
168
183
  this.changed(question);
169
- // Core routes the reply: follow-up into an active run, auto-resume of a
170
- // terminal one — the replier gets the continuation's callback.
171
- if (run.targetSessionId && (run.state === "queued" || run.state === "running")) {
184
+ this.changed(reply);
185
+ if (continuation)
186
+ this.startRun(continuation);
187
+ else if (run.targetSessionId)
172
188
  this.deliver(reply, run, run.targetSessionId);
173
- }
174
- else if (isTerminal(run.state)) {
175
- // Creating the continuation is not delivering the reply: the run is
176
- // queued, and a restart or a cancel before it starts loses the prompt
177
- // that carries the text. So the id is recorded and the sweep below
178
- // settles it against the continuation's own `startedAt`.
179
- reply.resumeRunId = this.resumeRun(run.id, this.format(reply, run), fromSessionId).id;
180
- this.store.saveMessage(reply);
181
- this.changed(reply);
182
- }
183
189
  return this.require(reply.id);
184
190
  }
185
191
  create(run, kind, fromSessionId, toSessionId, content, replyTo) {
@@ -200,7 +206,6 @@ export class TaskMessenger {
200
206
  nextAttemptAt: null,
201
207
  };
202
208
  this.store.saveMessage(message);
203
- this.changed(message);
204
209
  return message;
205
210
  }
206
211
  /** Never awaits the recipient: the seam's `systemInput` settles with the turn
@@ -377,6 +382,7 @@ export class TaskMessenger {
377
382
  throw new Error(`unknown task message: ${id}`);
378
383
  return message;
379
384
  }
385
+ /** Publish only after any transaction changing this message has committed. */
380
386
  changed(message) {
381
387
  this.hub.emitWorkspace({ type: "task-message-changed", runId: message.runId, messageId: message.id });
382
388
  }
@@ -3,10 +3,12 @@
3
3
  // this logic was written twice and the two copies drifted — the ceiling was
4
4
  // checked in one order here and another there, and only one of them counted a
5
5
  // failed attempt.
6
- import { Router } from "../core/router.js";
7
6
  import { logger } from "../log.js";
8
7
  import { MAX_DELIVERY_ATTEMPTS, retryDelay, undeliverable } from "./types.js";
9
8
  const log = logger("tasks");
9
+ /** The records a delivery names, from either side of the seam: a batch names
10
+ * every one of them, a group names itself. */
11
+ const callbackIds = (origin) => origin?.kind === "task-callback" ? origin.runIds ?? [origin.runId] : [];
10
12
  export class Outbox {
11
13
  router;
12
14
  kind;
@@ -35,6 +37,7 @@ export class Outbox {
35
37
  return;
36
38
  for (const record of mine)
37
39
  this.delivering.add(this.kind.id(record));
40
+ const counted = new Set();
38
41
  try {
39
42
  const session = await this.router.ensure({ channelId: "task", conversationId: sessionId });
40
43
  // The transcript read is both the crash-window dedupe and the proof.
@@ -46,40 +49,64 @@ export class Outbox {
46
49
  return;
47
50
  // Waiting for a busy target is not a delivery attempt: counting it would
48
51
  // inflate the attempts once per second and skip the failure backoff
49
- // straight to its ceiling.
50
- if (session.state === "streaming") {
52
+ // straight to its ceiling. A record delegated with `steer` is the
53
+ // exception it asked for — it joins the running turn instead, and the
54
+ // rest of the batch keeps waiting for the turn to end. But only once:
55
+ // handed over, a steer sits in Pi's in-memory queue, invisible in the
56
+ // transcript until the turn drains it, so the queue is the second place
57
+ // this has to look before deciding nothing arrived (messages.ts:340).
58
+ const streaming = session.state === "streaming";
59
+ let sending = live;
60
+ if (streaming) {
61
+ const handedOver = await this.queued(session);
62
+ const steerNow = (record) => record.callbackMode === "steer" && !handedOver.has(this.kind.id(record));
63
+ sending = live.filter(steerNow);
51
64
  for (const record of live)
52
- this.defer(record);
53
- return;
65
+ if (!steerNow(record))
66
+ this.defer(record);
67
+ if (sending.length === 0)
68
+ return;
54
69
  }
55
- for (const record of live)
70
+ for (const record of sending) {
56
71
  this.sent(record);
57
- const { text, origin } = this.kind.input(live);
58
- log.debug(`callback for ${live.map((r) => this.kind.id(r)).join(", ")} → session ${sessionId}`);
72
+ counted.add(this.kind.id(record));
73
+ }
74
+ const { text, origin } = this.kind.input(sending);
75
+ log.debug(`callback for ${sending.map((r) => this.kind.id(r)).join(", ")} → session ${sessionId}`);
59
76
  // Not awaited: `systemInput` settles with the recipient's whole turn, and
60
77
  // holding the delivery lock that long would keep the proof from ever
61
78
  // being read — which is the only thing that marks this delivered.
62
- session.systemInput(text, origin, "followUp")
63
- .catch((error) => this.retry(sessionId, live, error));
79
+ session.systemInput(text, origin, streaming ? "steer" : "followUp")
80
+ .catch((error) => this.retry(sessionId, sending, error, counted));
64
81
  // Pi records the input as it starts the turn, so the proof is usually
65
82
  // here already; the tick sweep is the backstop when it is not.
66
- await this.settle(live, session);
83
+ await this.settle(sending, session);
67
84
  }
68
85
  catch (error) {
69
- this.retry(sessionId, mine, error);
86
+ this.retry(sessionId, mine, error, counted);
70
87
  }
71
88
  finally {
72
89
  for (const record of mine)
73
90
  this.delivering.delete(this.kind.id(record));
74
91
  }
75
92
  }
93
+ /** Handed over and waiting in the recipient's queue for the running turn to
94
+ * drain it — what the transcript cannot answer yet. Empty unless the
95
+ * session is streaming: Pi drops the list when the turn ends. */
96
+ async queued(session) {
97
+ const ids = new Set();
98
+ for (const origin of await session.pendingSystemInputs())
99
+ for (const id of callbackIds(origin))
100
+ ids.add(id);
101
+ return ids;
102
+ }
76
103
  /** Marks every record the transcript proves; returns the ones it does not. */
77
104
  async settle(records, session) {
78
105
  const seen = new Set();
79
106
  for (const turn of await session.history()) {
80
- if (turn.role !== "system" || turn.origin?.kind !== "task-callback")
107
+ if (turn.role !== "system")
81
108
  continue;
82
- for (const id of turn.origin.runIds ?? [turn.origin.runId])
109
+ for (const id of callbackIds(turn.origin))
83
110
  seen.add(id);
84
111
  }
85
112
  const unproven = [];
@@ -95,14 +122,15 @@ export class Outbox {
95
122
  /** The recipient is waiting for an answer that is now late: the retry itself
96
123
  * is silent, so this line is the only sign it is being retried — and the
97
124
  * ceiling is what ends the retrying out loud. */
98
- retry(sessionId, batch, error) {
125
+ retry(sessionId, batch, error, counted) {
99
126
  log.warn(`callback to session ${sessionId} failed, will retry`, error);
100
127
  for (const stale of batch) {
101
128
  const record = this.kind.reload(this.kind.id(stale));
102
129
  // Already proven delivered, or already given up on: not a failure.
103
130
  if (!record || (record.callbackState !== "pending" && record.callbackState !== "failed"))
104
131
  continue;
105
- this.failed(record, error);
132
+ this.failed(record, error, counted.has(this.kind.id(record)));
133
+ counted.add(this.kind.id(record));
106
134
  this.spent(record, sessionId);
107
135
  }
108
136
  }
@@ -140,11 +168,10 @@ export class Outbox {
140
168
  record.callbackNextAttemptAt = null;
141
169
  this.write(record);
142
170
  }
143
- /** Counts the attempt too: a target that fails before anything is sent (a
144
- * transcript that no longer exists) would otherwise retry at attempt 0 for
145
- * as long as the process lives, never reaching the ceiling. */
146
- failed(record, error) {
147
- record.callbackAttempts += 1;
171
+ /** A failed pass costs one attempt, whether it died before or after send. */
172
+ failed(record, error, counted) {
173
+ if (!counted)
174
+ record.callbackAttempts += 1;
148
175
  record.callbackState = "failed";
149
176
  record.callbackError = String(error);
150
177
  record.callbackNextAttemptAt = Date.now() + retryDelay(record.callbackAttempts);
@@ -51,10 +51,7 @@ export function registerTaskRoutes(app, tasks, activity) {
51
51
  const now = Date.now();
52
52
  const recent = c.req.query("scope") === "recent";
53
53
  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));
54
+ const runs = tasks.activityRuns(recent ? windowStart : undefined);
58
55
  const listed = await activity.factory.list();
59
56
  const byId = new Map(listed.map((session) => [session.id, session]));
60
57
  const linkedIds = new Set();
@@ -4,9 +4,7 @@
4
4
  // exploding (depth, children per root) are decided here, once, because every
5
5
  // caller — scheduler, tool, HTTP — enqueues through this one door.
6
6
  import { logger } from "../log.js";
7
- import { TaskCallbacks } from "./callbacks.js";
8
7
  import { newId } from "./definitions.js";
9
- import { TaskStore } from "./store.js";
10
8
  const log = logger("tasks");
11
9
  const MAX_DEPTH = 2;
12
10
  const MAX_CHILDREN_PER_ROOT = 16;
@@ -23,7 +21,8 @@ export class TaskRunQueue {
23
21
  this.execute = execute;
24
22
  this.changed = changed;
25
23
  }
26
- enqueue(definition, input, source, parentRunId, provenance) {
24
+ /** Validate and persist only; callers may stage several runs in a transaction. */
25
+ prepare(definition, input, source, parentRunId, provenance) {
27
26
  const id = newId();
28
27
  const parent = parentRunId ? this.getRun(parentRunId) : null;
29
28
  const depth = provenance.depth ?? (parent ? parent.depth + 1 : 0);
@@ -63,6 +62,7 @@ export class TaskRunQueue {
63
62
  targetSessionId,
64
63
  sessionMode,
65
64
  callbackSessionId,
65
+ ...(provenance.callbackMode === "steer" ? { callbackMode: "steer" } : {}),
66
66
  background: provenance.background ?? false,
67
67
  callbackState: overlapped && callbackSessionId ? "pending" : null,
68
68
  callbackAttempts: 0,
@@ -84,7 +84,14 @@ export class TaskRunQueue {
84
84
  finishedAt: overlapped ? now : null,
85
85
  };
86
86
  this.store.saveRun(run);
87
+ return run;
88
+ }
89
+ /** Publish and execute only after all records this run relies on committed. */
90
+ start(run) {
87
91
  this.changed(run);
92
+ const { id, depth, triggerSource: source } = run;
93
+ const overlapped = run.skipReason === "overlap";
94
+ const definition = run.context.definition;
88
95
  // Why a run exists is the first question asked of a surprising one, and it
89
96
  // is answerable only here: the row keeps the ids, not the reason. A watch
90
97
  // probe queues on every interval and mostly matches nothing, so it says so
@@ -99,6 +106,5 @@ export class TaskRunQueue {
99
106
  this.execute(run);
100
107
  else if (run.callbackState === "pending")
101
108
  void this.callbacks.deliver(run);
102
- return run;
103
109
  }
104
110
  }
@@ -4,8 +4,6 @@
4
4
  // like it makes belongs to a file beside it (definitions, runs, execution,
5
5
  // groups, messages, callbacks) — what is genuinely here is scheduling and the
6
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";
9
7
  import { logger } from "../log.js";
10
8
  import { AgentTaskRunner } from "./agent.js";
11
9
  import { TaskCallbacks } from "./callbacks.js";
@@ -14,7 +12,6 @@ import { TaskExecution } from "./execution.js";
14
12
  import { TaskGroups } from "./groups.js";
15
13
  import { TaskMessenger } from "./messages.js";
16
14
  import { TaskRunQueue } from "./runs.js";
17
- import { TaskStore } from "./store.js";
18
15
  import { handleTaskTool } from "./tool.js";
19
16
  import { isTerminal } from "./types.js";
20
17
  const log = logger("tasks");
@@ -52,20 +49,21 @@ export class TaskService {
52
49
  this.hub = hub;
53
50
  this.instance = instance;
54
51
  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);
52
+ 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
53
  this.definitions = new TaskDefinitions(store, factory, router, hub, instance?.systemActions);
57
54
  this.callbacks = new TaskCallbacks(store, router, (run) => this.changed(run), unreachable);
58
55
  this.groups = new TaskGroups(store, router, {
59
56
  getRun: (id) => this.getRun(id),
60
57
  cancel: (id) => { this.cancel(id); },
61
58
  openDecisionId: (runId) => this.messages.openDecisionId(runId),
62
- startMember: (taskId, groupId, callerSessionId, parentRunId) => this.run(taskId, null, "agent", parentRunId, {
59
+ prepareMember: (taskId, groupId, callerSessionId, parentRunId) => this.prepareRun(taskId, null, "agent", parentRunId, {
63
60
  invokedBySessionId: callerSessionId,
64
61
  sourceSessionId: callerSessionId,
65
62
  callbackSessionId: null,
66
63
  background: true,
67
64
  groupId,
68
65
  }),
66
+ startMember: (run) => this.runs.start(run),
69
67
  }, (group) => this.hub.emitWorkspace({ type: "task-group-changed", groupId: group.id }), unreachable);
70
68
  const agent = new AgentTaskRunner(factory, router, store, this.messages, (run) => this.changed(run));
71
69
  this.execution = new TaskExecution(store, this.definitions, this.callbacks, agent, {
@@ -198,8 +196,8 @@ export class TaskService {
198
196
  queryRuns(query = {}) {
199
197
  return this.store.queryRuns(query);
200
198
  }
201
- recentRuns(limit = 100) {
202
- return this.store.listRecentRuns(limit);
199
+ activityRuns(since) {
200
+ return this.store.activityRuns(since);
203
201
  }
204
202
  recentMessages(since) {
205
203
  return this.messages.recent(since);
@@ -222,13 +220,18 @@ export class TaskService {
222
220
  return this.store.taskOwnedSessionIds();
223
221
  }
224
222
  run(taskId, input = null, source = "manual", parentRunId = null, provenance = {}) {
223
+ const run = this.prepareRun(taskId, input, source, parentRunId, provenance);
224
+ this.runs.start(run);
225
+ return run;
226
+ }
227
+ prepareRun(taskId, input, source, parentRunId, provenance) {
225
228
  this.refusePaused(parentRunId);
226
229
  const task = this.get(taskId);
227
230
  // `enabled:false` pauses scheduling only; manual and agent triggers still
228
231
  // run a paused task on demand. Archiving is the terminal state.
229
232
  if (task.archived)
230
233
  throw new Error("archived tasks cannot run");
231
- return this.runs.enqueue(task, input, source, parentRunId, provenance);
234
+ return this.runs.prepare(task, input, source, parentRunId, provenance);
232
235
  }
233
236
  async waitForRun(id) {
234
237
  const current = this.getRun(id);
@@ -256,9 +259,9 @@ export class TaskService {
256
259
  getGroup(id) {
257
260
  return this.groups.members(id);
258
261
  }
259
- runGroup(definitions, join, callerSessionId, parentRunId, callbackSessionId) {
262
+ runGroup(definitions, join, callerSessionId, parentRunId, callbackSessionId, callbackMode) {
260
263
  this.refusePaused(parentRunId);
261
- return this.groups.runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId);
264
+ return this.groups.runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId, callbackMode);
262
265
  }
263
266
  descendants(run) {
264
267
  const byParent = new Map();
@@ -291,6 +294,16 @@ export class TaskService {
291
294
  return this.messages.reply(messageId, fromSessionId, message);
292
295
  }
293
296
  resume(id, message, provenance = {}) {
297
+ const { run, expired } = this.store.transact(() => ({
298
+ run: this.prepareResume(id, message, provenance),
299
+ expired: this.messages.expireDecisions(id, "superseded by a manual resume"),
300
+ }));
301
+ for (const message of expired)
302
+ this.messages.changed(message);
303
+ this.runs.start(run);
304
+ return run;
305
+ }
306
+ prepareResume(id, message, provenance) {
294
307
  this.refusePaused();
295
308
  const prior = this.getRun(id);
296
309
  if (!isTerminal(prior.state))
@@ -299,8 +312,7 @@ export class TaskService {
299
312
  throw new Error("only persisted Agent runs can be resumed");
300
313
  }
301
314
  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, {
315
+ return this.runs.prepare(prior.context.definition, null, "agent", null, {
304
316
  ...provenance,
305
317
  sourceSessionId: provenance.invokedBySessionId ?? prior.invokedBySessionId,
306
318
  targetSessionId: prior.targetSessionId,
@@ -2,7 +2,7 @@
2
2
  // runs, groups and messages are rows here, read and written through one
3
3
  // connection db.ts opened. A store owns its queries, never its own tables or
4
4
  // its own handle — the schema is db.ts's migration list.
5
- import { pierDb, statements } from "../db.js";
5
+ import { pierDb, statements, transact } from "../db.js";
6
6
  const clamp = (limit, cap) => Math.min(Math.max(limit, 1), cap);
7
7
  export class TaskStore {
8
8
  db;
@@ -12,6 +12,10 @@ export class TaskStore {
12
12
  this.db = db;
13
13
  this.sql = statements(db);
14
14
  }
15
+ /** Commit related task records before publishing events or starting work. */
16
+ transact(work) {
17
+ return transact(this.db, work);
18
+ }
15
19
  // Every table is one JSON column plus query columns; these two are the only
16
20
  // readers, and the one seam where a future schema change normalizes old rows
17
21
  // (pre-v1 databases are refused outright in db.ts).
@@ -101,8 +105,18 @@ export class TaskStore {
101
105
  nextCursor: rows.length > limit && last ? { queuedAt: last.queuedAt, id: last.id } : null,
102
106
  };
103
107
  }
104
- listRecentRuns(limit = 100) {
105
- return this.#many("SELECT json FROM task_runs ORDER BY queued_at DESC LIMIT ?", clamp(limit, 500));
108
+ /** Activity never limits live work; only its optional history is bounded. */
109
+ activityRuns(since) {
110
+ const active = this.#many("SELECT json FROM task_runs WHERE state IN ('queued', 'running') ORDER BY queued_at DESC, id DESC");
111
+ if (since === undefined)
112
+ return active;
113
+ const recent = this.#many(`
114
+ SELECT json FROM task_runs
115
+ WHERE state NOT IN ('queued', 'running') AND queued_at >= ?
116
+ AND NOT (state = 'succeeded' AND json_extract(json, '$.matched') IS 0)
117
+ ORDER BY queued_at DESC, id DESC LIMIT 200
118
+ `, since);
119
+ return [...active, ...recent].sort((a, b) => b.queuedAt - a.queuedAt || b.id.localeCompare(a.id));
106
120
  }
107
121
  listRunsByRoot(rootRunId, limit = 100) {
108
122
  return this.#many(`