@timqi/pier 0.1.3 → 0.1.4
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.
- package/README.md +8 -3
- package/dist/agent/config.js +9 -3
- package/dist/agent/events.js +1 -2
- package/dist/agent/packages.js +1 -7
- package/dist/agent/pi.js +6 -47
- package/dist/channels/config.js +30 -20
- package/dist/channels/routes.js +0 -3
- package/dist/channels/slack-api.js +19 -10
- package/dist/channels/slack-cli.js +503 -0
- package/dist/channels/slack-directory.js +2 -2
- package/dist/channels/slack-outbound.js +1 -1
- package/dist/channels/slack-thread.js +41 -0
- package/dist/channels/slack-transcript.js +107 -0
- package/dist/channels/slack.js +9 -17
- package/dist/channels/types.js +0 -1
- package/dist/cli.js +158 -7
- package/dist/core/identity.js +20 -11
- package/dist/core/reply.js +16 -15
- package/dist/core/router.js +4 -1
- package/dist/db.js +28 -0
- package/dist/main.js +19 -32
- package/dist/paths.js +3 -0
- package/dist/secrets.js +2 -1
- package/dist/socket.js +95 -0
- package/dist/tasks/agent.js +5 -6
- package/dist/tasks/callbacks.js +2 -2
- package/dist/tasks/cli.js +215 -0
- package/dist/tasks/definitions.js +3 -3
- package/dist/tasks/execution.js +1 -3
- package/dist/tasks/groups.js +2 -5
- package/dist/tasks/messages.js +18 -158
- package/dist/tasks/operations.js +323 -0
- package/dist/tasks/routes.js +1 -11
- package/dist/tasks/runs.js +5 -20
- package/dist/tasks/service.js +18 -43
- package/dist/tasks/store.js +15 -15
- package/dist/tools.js +17 -0
- package/dist/vault.js +107 -0
- package/dist/web/auth.js +2 -1
- package/dist/web/public/assets/{activity-BSMeRcN2.js → activity-NLS8W9yl.js} +2 -2
- package/dist/web/public/assets/activity-NLS8W9yl.js.br +0 -0
- package/dist/web/public/assets/activity-NLS8W9yl.js.gz +0 -0
- package/dist/web/public/assets/{boards-BCWQMZry.js → boards-DleegoiC.js} +1 -1
- package/dist/web/public/assets/boards-DleegoiC.js.br +0 -0
- package/dist/web/public/assets/boards-DleegoiC.js.gz +0 -0
- package/dist/web/public/assets/{explorer-Cr4XTi4j.js → explorer-vJvV1Sx0.js} +1 -1
- package/dist/web/public/assets/explorer-vJvV1Sx0.js.br +0 -0
- package/dist/web/public/assets/explorer-vJvV1Sx0.js.gz +0 -0
- package/dist/web/public/assets/{index-DVIt5Gio.css → index-Bs-gol9o.css} +1 -1
- package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
- package/dist/web/public/assets/{index-DVIt5Gio.css.gz → index-Bs-gol9o.css.gz} +0 -0
- package/dist/web/public/assets/index-Cyp2DKKF.js +85 -0
- package/dist/web/public/assets/index-Cyp2DKKF.js.br +0 -0
- package/dist/web/public/assets/index-Cyp2DKKF.js.gz +0 -0
- package/dist/web/public/assets/{runs-DdERzeac.js → runs-C5FveZ75.js} +1 -1
- package/dist/web/public/assets/runs-C5FveZ75.js.br +0 -0
- package/dist/web/public/assets/runs-C5FveZ75.js.gz +0 -0
- package/dist/web/public/assets/settings-nJHIBbAz.js +5 -0
- package/dist/web/public/assets/settings-nJHIBbAz.js.br +0 -0
- package/dist/web/public/assets/settings-nJHIBbAz.js.gz +0 -0
- package/dist/web/public/assets/{task-runs-C-dGUDsH.js → task-runs-CJJ2j5Ks.js} +1 -1
- package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.br +0 -0
- package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.gz +0 -0
- package/dist/web/public/assets/{tasks-CY30H1u1.js → tasks-R2Pv0Abg.js} +1 -1
- package/dist/web/public/assets/tasks-R2Pv0Abg.js.br +0 -0
- package/dist/web/public/assets/tasks-R2Pv0Abg.js.gz +0 -0
- package/dist/web/public/index.html +2 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/vault.js +68 -0
- package/docs/deploy.md +33 -10
- package/package.json +1 -1
- package/skills/pier-help/SKILL.md +10 -6
- package/skills/pier-slack/SKILL.md +52 -71
- package/skills/pier-tasks/SKILL.md +48 -145
- package/skills/pier-vault/SKILL.md +36 -0
- package/dist/channels/slack-tool.js +0 -416
- package/dist/tasks/tool.js +0 -416
- package/dist/web/public/assets/activity-BSMeRcN2.js.br +0 -0
- package/dist/web/public/assets/activity-BSMeRcN2.js.gz +0 -0
- package/dist/web/public/assets/boards-BCWQMZry.js.br +0 -0
- package/dist/web/public/assets/boards-BCWQMZry.js.gz +0 -0
- package/dist/web/public/assets/explorer-Cr4XTi4j.js.br +0 -0
- package/dist/web/public/assets/explorer-Cr4XTi4j.js.gz +0 -0
- package/dist/web/public/assets/index-C7tA0Ufu.js +0 -85
- package/dist/web/public/assets/index-C7tA0Ufu.js.br +0 -0
- package/dist/web/public/assets/index-C7tA0Ufu.js.gz +0 -0
- package/dist/web/public/assets/index-DVIt5Gio.css.br +0 -0
- package/dist/web/public/assets/runs-DdERzeac.js.br +0 -0
- package/dist/web/public/assets/runs-DdERzeac.js.gz +0 -0
- package/dist/web/public/assets/settings-CQDAHoMM.js +0 -5
- package/dist/web/public/assets/settings-CQDAHoMM.js.br +0 -0
- package/dist/web/public/assets/settings-CQDAHoMM.js.gz +0 -0
- package/dist/web/public/assets/task-runs-C-dGUDsH.js.br +0 -0
- package/dist/web/public/assets/task-runs-C-dGUDsH.js.gz +0 -0
- package/dist/web/public/assets/tasks-CY30H1u1.js.br +0 -0
- package/dist/web/public/assets/tasks-CY30H1u1.js.gz +0 -0
package/dist/tasks/messages.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
// What a parent
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// What a parent says to a child while a run is going. Every message is a
|
|
2
|
+
// durable row before it is a delivery: the child may be mid-turn or gone, and
|
|
3
|
+
// an undelivered message is retried, expired and said, never dropped (§5).
|
|
4
|
+
// Delivery itself belongs to outbox.ts.
|
|
5
5
|
import { runSource } from "./callbacks.js";
|
|
6
6
|
import { newId } from "./definitions.js";
|
|
7
7
|
import { Outbox } from "./outbox.js";
|
|
8
8
|
import { isTerminal } from "./types.js";
|
|
9
9
|
const MAX_MESSAGE_LENGTH = 16 * 1024;
|
|
10
|
-
/** `
|
|
10
|
+
/** `expired` is a delivery given up on. */
|
|
11
11
|
const ENGINE_STATE = {
|
|
12
|
-
pending: "pending", failed: "failed", delivered: "delivered",
|
|
12
|
+
pending: "pending", failed: "failed", delivered: "delivered", expired: "abandoned",
|
|
13
13
|
};
|
|
14
14
|
function bounded(content) {
|
|
15
15
|
const text = content.trim();
|
|
@@ -22,20 +22,14 @@ function bounded(content) {
|
|
|
22
22
|
export class TaskMessenger {
|
|
23
23
|
store;
|
|
24
24
|
hub;
|
|
25
|
-
prepareResume;
|
|
26
25
|
unreachable;
|
|
27
|
-
startRun;
|
|
28
26
|
outbox;
|
|
29
27
|
constructor(store, router, hub,
|
|
30
|
-
/** Prepares a continuation; it starts only after the reply commits. */
|
|
31
|
-
prepareResume,
|
|
32
28
|
/** Reports a delivery nobody can complete (service.ts owns the surfaces). */
|
|
33
|
-
unreachable
|
|
29
|
+
unreachable) {
|
|
34
30
|
this.store = store;
|
|
35
31
|
this.hub = hub;
|
|
36
|
-
this.prepareResume = prepareResume;
|
|
37
32
|
this.unreachable = unreachable;
|
|
38
|
-
this.startRun = startRun;
|
|
39
33
|
this.outbox = new Outbox(router, {
|
|
40
34
|
id: ({ message }) => message.id,
|
|
41
35
|
reload: (id) => {
|
|
@@ -50,10 +44,8 @@ export class TaskMessenger {
|
|
|
50
44
|
message.state = "delivered";
|
|
51
45
|
message.deliveredAt = Date.now();
|
|
52
46
|
}
|
|
53
|
-
else if (callbackState === "abandoned")
|
|
47
|
+
else if (callbackState === "abandoned")
|
|
54
48
|
message.state = "expired";
|
|
55
|
-
message.answeredAt = Date.now();
|
|
56
|
-
}
|
|
57
49
|
else
|
|
58
50
|
message.state = callbackState ?? "pending";
|
|
59
51
|
this.store.saveMessage(message);
|
|
@@ -76,25 +68,6 @@ export class TaskMessenger {
|
|
|
76
68
|
for (const message of this.store.expirePendingMessages())
|
|
77
69
|
this.changed(message);
|
|
78
70
|
}
|
|
79
|
-
/** The unanswered decision on a run, if any. */
|
|
80
|
-
openDecisionId(runId) {
|
|
81
|
-
return this.store.listMessages(runId).find((m) => m.kind === "decision" && (m.state === "pending" || m.state === "delivered"))?.id ?? null;
|
|
82
|
-
}
|
|
83
|
-
/** A manual continuation supersedes an unanswered decision (design 04):
|
|
84
|
-
* one continuation per run, never two racing ones. */
|
|
85
|
-
expireDecisions(runId, reason) {
|
|
86
|
-
const expired = [];
|
|
87
|
-
for (const message of this.store.listMessages(runId)) {
|
|
88
|
-
if (message.kind !== "decision" || (message.state !== "pending" && message.state !== "delivered"))
|
|
89
|
-
continue;
|
|
90
|
-
message.state = "expired";
|
|
91
|
-
message.error = reason;
|
|
92
|
-
message.answeredAt = Date.now();
|
|
93
|
-
this.store.saveMessage(message);
|
|
94
|
-
expired.push(message);
|
|
95
|
-
}
|
|
96
|
-
return expired;
|
|
97
|
-
}
|
|
98
71
|
list(runId) {
|
|
99
72
|
return this.store.listMessages(runId);
|
|
100
73
|
}
|
|
@@ -102,7 +75,7 @@ export class TaskMessenger {
|
|
|
102
75
|
return this.store.listRecentMessages(since);
|
|
103
76
|
}
|
|
104
77
|
async control(run, fromSessionId, kind, content) {
|
|
105
|
-
const message = this.create(run, kind, fromSessionId, run.targetSessionId ?? "", content
|
|
78
|
+
const message = this.create(run, kind, fromSessionId, run.targetSessionId ?? "", content);
|
|
106
79
|
this.changed(message);
|
|
107
80
|
if (run.targetSessionId)
|
|
108
81
|
this.deliver(message, run.targetSessionId);
|
|
@@ -112,9 +85,8 @@ export class TaskMessenger {
|
|
|
112
85
|
if (!run.targetSessionId)
|
|
113
86
|
return;
|
|
114
87
|
for (const message of this.store.listMessages(run.id)) {
|
|
115
|
-
if (message.state
|
|
116
|
-
|
|
117
|
-
this.deliver(message, run.targetSessionId);
|
|
88
|
+
if (message.state === "pending")
|
|
89
|
+
this.deliver(message, run.targetSessionId);
|
|
118
90
|
}
|
|
119
91
|
}
|
|
120
92
|
/** Delivery is fire-and-forget, so this sweep is what closes a failed one.
|
|
@@ -124,30 +96,13 @@ export class TaskMessenger {
|
|
|
124
96
|
if (!run)
|
|
125
97
|
continue;
|
|
126
98
|
// Expiring a dead control is not a retry, so it ignores the backoff.
|
|
127
|
-
if (
|
|
99
|
+
if (isTerminal(run.state)) {
|
|
128
100
|
message.state = "expired";
|
|
129
101
|
message.error = "run finished before delivery completed";
|
|
130
102
|
this.store.saveMessage(message);
|
|
131
103
|
this.changed(message);
|
|
132
104
|
continue;
|
|
133
105
|
}
|
|
134
|
-
// A reply that resumed a terminal run is the continuation's prompt, not a
|
|
135
|
-
// system input: the transcript read would re-inject it and start a turn no
|
|
136
|
-
// run owns. The continuation's `startedAt` is the proof instead.
|
|
137
|
-
if (message.kind === "reply" && isTerminal(run.state)) {
|
|
138
|
-
const resumed = message.resumeRunId ? this.store.getRun(message.resumeRunId) : undefined;
|
|
139
|
-
if (resumed?.startedAt)
|
|
140
|
-
this.delivered(message);
|
|
141
|
-
else if (!resumed)
|
|
142
|
-
this.abandon(message, `continuation ${message.resumeRunId ?? "(none)"} is gone`);
|
|
143
|
-
else if (isTerminal(resumed.state)) {
|
|
144
|
-
// Never started and never will: said now, not four minutes later at
|
|
145
|
-
// the ceiling. A duplicate report beats a special case that could
|
|
146
|
-
// suppress the only one.
|
|
147
|
-
this.abandon(message, `continuation ${resumed.id} ${resumed.state} before it started`);
|
|
148
|
-
}
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
106
|
if ((message.nextAttemptAt ?? 0) > now)
|
|
152
107
|
continue;
|
|
153
108
|
const target = message.toSessionId || run.targetSessionId;
|
|
@@ -155,76 +110,17 @@ export class TaskMessenger {
|
|
|
155
110
|
this.deliver(message, target);
|
|
156
111
|
}
|
|
157
112
|
}
|
|
158
|
-
|
|
159
|
-
* run) or resumes the session (terminal run). A decision steers the
|
|
160
|
-
* supervisor, or a blocked child would wait out its whole turn. */
|
|
161
|
-
async contact(run, fromSessionId, reason, content) {
|
|
162
|
-
if (!run.invokedBySessionId)
|
|
163
|
-
throw new Error("run has no supervisor session");
|
|
164
|
-
if (reason === "decision" && this.openDecisionId(run.id)) {
|
|
165
|
-
throw new Error("run already has a pending supervisor decision");
|
|
166
|
-
}
|
|
167
|
-
const message = this.create(run, reason, fromSessionId, run.invokedBySessionId, content, null);
|
|
168
|
-
this.changed(message);
|
|
169
|
-
this.deliver(message, run.invokedBySessionId);
|
|
170
|
-
return this.require(message.id);
|
|
171
|
-
}
|
|
172
|
-
async reply(questionId, fromSessionId, content) {
|
|
173
|
-
const question = this.require(questionId);
|
|
174
|
-
if (question.kind !== "decision")
|
|
175
|
-
throw new Error("message is not a decision request");
|
|
176
|
-
if (question.toSessionId !== fromSessionId)
|
|
177
|
-
throw new Error("only the addressed supervisor may reply");
|
|
178
|
-
const existing = this.store.listMessages(question.runId).find((m) => m.kind === "reply" && m.replyTo === question.id);
|
|
179
|
-
const text = bounded(content);
|
|
180
|
-
if (existing) {
|
|
181
|
-
if (existing.content !== text)
|
|
182
|
-
throw new Error("decision already answered with different content");
|
|
183
|
-
return existing;
|
|
184
|
-
}
|
|
185
|
-
if (question.state !== "delivered" && question.state !== "pending") {
|
|
186
|
-
throw new Error(`decision is ${question.state}`);
|
|
187
|
-
}
|
|
188
|
-
const run = this.store.getRun(question.runId);
|
|
189
|
-
if (!run)
|
|
190
|
-
throw new Error(`unknown task run: ${question.runId}`);
|
|
191
|
-
// A rejected continuation must leave the question answerable. Persist the
|
|
192
|
-
// answer and its queued run together, before either publishes or executes.
|
|
193
|
-
const { reply, continuation } = this.store.transact(() => {
|
|
194
|
-
const reply = this.create(run, "reply", fromSessionId, question.fromSessionId, text, question.id);
|
|
195
|
-
question.state = "answered";
|
|
196
|
-
question.answeredAt = Date.now();
|
|
197
|
-
this.store.saveMessage(question);
|
|
198
|
-
const continuation = isTerminal(run.state)
|
|
199
|
-
? this.prepareResume(run.id, this.format(reply, run), fromSessionId)
|
|
200
|
-
: undefined;
|
|
201
|
-
if (continuation) {
|
|
202
|
-
reply.resumeRunId = continuation.id;
|
|
203
|
-
this.store.saveMessage(reply);
|
|
204
|
-
}
|
|
205
|
-
return { reply, continuation };
|
|
206
|
-
});
|
|
207
|
-
this.changed(question);
|
|
208
|
-
this.changed(reply);
|
|
209
|
-
if (continuation)
|
|
210
|
-
this.startRun(continuation);
|
|
211
|
-
else if (run.targetSessionId)
|
|
212
|
-
this.deliver(reply, run.targetSessionId);
|
|
213
|
-
return this.require(reply.id);
|
|
214
|
-
}
|
|
215
|
-
create(run, kind, fromSessionId, toSessionId, content, replyTo) {
|
|
113
|
+
create(run, kind, fromSessionId, toSessionId, content) {
|
|
216
114
|
const message = {
|
|
217
115
|
id: newId(),
|
|
218
116
|
runId: run.id,
|
|
219
117
|
kind,
|
|
220
118
|
fromSessionId,
|
|
221
119
|
toSessionId,
|
|
222
|
-
replyTo,
|
|
223
120
|
state: "pending",
|
|
224
121
|
content: bounded(content),
|
|
225
122
|
createdAt: Date.now(),
|
|
226
123
|
deliveredAt: null,
|
|
227
|
-
answeredAt: null,
|
|
228
124
|
error: null,
|
|
229
125
|
attempts: 0,
|
|
230
126
|
nextAttemptAt: null,
|
|
@@ -247,56 +143,20 @@ export class TaskMessenger {
|
|
|
247
143
|
callbackAttempts: message.attempts,
|
|
248
144
|
callbackError: message.error,
|
|
249
145
|
callbackNextAttemptAt: message.nextAttemptAt,
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
...(message.kind === "follow_up"
|
|
146
|
+
// A steer interrupts; a follow-up lands only once the recipient runs out
|
|
147
|
+
// of tool calls.
|
|
148
|
+
...(message.kind === "follow_up" ? {} : { callbackMode: "steer" }),
|
|
253
149
|
};
|
|
254
150
|
}
|
|
255
|
-
/**
|
|
256
|
-
delivered(message) {
|
|
257
|
-
message.state = "delivered";
|
|
258
|
-
message.deliveredAt = Date.now();
|
|
259
|
-
message.error = null;
|
|
260
|
-
message.nextAttemptAt = null;
|
|
261
|
-
this.store.saveMessage(message);
|
|
262
|
-
this.changed(message);
|
|
263
|
-
}
|
|
264
|
-
abandon(message, why) {
|
|
265
|
-
message.state = "expired";
|
|
266
|
-
message.error = why;
|
|
267
|
-
message.answeredAt = Date.now();
|
|
268
|
-
message.nextAttemptAt = null;
|
|
269
|
-
this.store.saveMessage(message);
|
|
270
|
-
this.changed(message);
|
|
271
|
-
this.told(message, why);
|
|
272
|
-
}
|
|
273
|
-
/** Both ends are told, and an expired decision stops suppressing its run's
|
|
274
|
-
* completion callback, which `execution.ts` decided once and never revisits. */
|
|
151
|
+
/** Both ends are told. */
|
|
275
152
|
told(message, why) {
|
|
276
153
|
this.unreachable(message.toSessionId, `a ${message.kind} from run ${message.runId}`, why);
|
|
277
154
|
if (message.fromSessionId && message.fromSessionId !== message.toSessionId) {
|
|
278
155
|
this.unreachable(message.fromSessionId, `your ${message.kind} on run ${message.runId}`, why);
|
|
279
156
|
}
|
|
280
|
-
const run = this.store.getRun(message.runId);
|
|
281
|
-
if (message.kind !== "decision" || !run?.callbackSessionId)
|
|
282
|
-
return;
|
|
283
|
-
if (run.callbackState === null && isTerminal(run.state)) {
|
|
284
|
-
run.callbackState = "pending"; // the tick sweep delivers it
|
|
285
|
-
this.store.saveRun(run);
|
|
286
|
-
}
|
|
287
157
|
}
|
|
288
158
|
format(message, run) {
|
|
289
|
-
|
|
290
|
-
if (message.kind === "progress") {
|
|
291
|
-
return `Subagent progress for task "${title}"\nRun: ${run.id}\nMessage: ${message.id}\n\n${message.content}`;
|
|
292
|
-
}
|
|
293
|
-
if (message.kind === "decision") {
|
|
294
|
-
return `Subagent needs a decision for task "${title}"\nRun: ${run.id}\nMessage: ${message.id}\nReply with the Task reply operation.\n\n${message.content}`;
|
|
295
|
-
}
|
|
296
|
-
if (message.kind === "reply") {
|
|
297
|
-
return `Supervisor reply for task "${title}"\nRun: ${run.id}\nReply to: ${message.replyTo ?? "-"}\n\n${message.content}`;
|
|
298
|
-
}
|
|
299
|
-
return `Task guidance for "${title}"\nRun: ${run.id}\nMessage: ${message.id}\n\n${message.content}`;
|
|
159
|
+
return `Task guidance for "${run.context.definition.name}"\nRun: ${run.id}\nMessage: ${message.id}\n\n${message.content}`;
|
|
300
160
|
}
|
|
301
161
|
origin(message, run) {
|
|
302
162
|
return {
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// The `/task` socket route: every `pier task` operation — run, message, save,
|
|
2
|
+
// list, cancel, recover — validated here once, answered with the summaries a
|
|
3
|
+
// model reads. Scheduling and delivery stay in the service; this file decides
|
|
4
|
+
// who may ask for what.
|
|
5
|
+
import { isAbsolute, resolve } from "node:path";
|
|
6
|
+
import { logger } from "../log.js";
|
|
7
|
+
import { record, requiredString } from "./definitions.js";
|
|
8
|
+
import { isTerminal } from "./types.js";
|
|
9
|
+
const log = logger("tasks");
|
|
10
|
+
/** Absent instead of `null`: a model reads both the same way, and on a group
|
|
11
|
+
* summary the nulls are a third of the payload. The input names every field
|
|
12
|
+
* so a summary that forgot one cannot pass as "empty". */
|
|
13
|
+
const defined = (value) => Object.fromEntries(Object.entries(value).filter(([, v]) => v !== null && v !== undefined));
|
|
14
|
+
const summarize = (run) => defined({
|
|
15
|
+
runId: run.id,
|
|
16
|
+
taskId: run.taskId,
|
|
17
|
+
taskName: run.context.definition.name,
|
|
18
|
+
state: run.state,
|
|
19
|
+
triggerSource: run.triggerSource,
|
|
20
|
+
groupId: run.groupId,
|
|
21
|
+
sessionMode: run.sessionMode,
|
|
22
|
+
targetSessionId: run.targetSessionId,
|
|
23
|
+
callbackSessionId: run.callbackSessionId,
|
|
24
|
+
callbackState: run.callbackState,
|
|
25
|
+
callbackMode: run.callbackMode ?? null,
|
|
26
|
+
queuedAt: run.queuedAt,
|
|
27
|
+
startedAt: run.startedAt,
|
|
28
|
+
finishedAt: run.finishedAt,
|
|
29
|
+
result: run.result,
|
|
30
|
+
error: run.error,
|
|
31
|
+
skipReason: run.skipReason,
|
|
32
|
+
next: null,
|
|
33
|
+
});
|
|
34
|
+
/** The receipt says the callback is the whole answer, or a model that just
|
|
35
|
+
* launched work reaches for a status call. */
|
|
36
|
+
const receipt = (summary, callbackSessionId, mode, callerSessionId) => ({
|
|
37
|
+
...summary,
|
|
38
|
+
next: callbackSessionId === null
|
|
39
|
+
? "callback none: the result is not delivered to anyone"
|
|
40
|
+
: callbackSessionId !== callerSessionId
|
|
41
|
+
? `the result is delivered to session ${callbackSessionId}; this session will not receive a callback`
|
|
42
|
+
: mode === "steer"
|
|
43
|
+
? "the result interrupts your running turn as a steer message; nothing to query"
|
|
44
|
+
: "the result arrives as a callback message once your turn ends; nothing to query",
|
|
45
|
+
});
|
|
46
|
+
/** A typo in an enum field must not silently mean its default. */
|
|
47
|
+
const oneOf = (input, key, values) => {
|
|
48
|
+
const value = input[key];
|
|
49
|
+
if (value === undefined || values.includes(value))
|
|
50
|
+
return value;
|
|
51
|
+
throw new Error(`${key} must be one of ${values.join(", ")}`);
|
|
52
|
+
};
|
|
53
|
+
const callbackModeOf = (input) => oneOf(input, "callback", ["origin", "none", "steer"]) === "steer" ? "steer" : "followUp";
|
|
54
|
+
/** Who a new run's result goes to — the caller, nobody, or a named session that
|
|
55
|
+
* must exist. Shared by `run` and `message` on a finished run: a resumed run is a new run. */
|
|
56
|
+
const callbackTarget = async (input, definitions, callerSessionId) => {
|
|
57
|
+
if (input.callback_session_id === undefined)
|
|
58
|
+
return input.callback === "none" ? null : callerSessionId;
|
|
59
|
+
if (input.callback === "none")
|
|
60
|
+
throw new Error("callback none and callback_session_id conflict: pick one delivery target");
|
|
61
|
+
const target = requiredString(input.callback_session_id, "callback_session_id");
|
|
62
|
+
if (!(await definitions.sessionExists(target)))
|
|
63
|
+
throw new Error(`unknown session: ${target}`);
|
|
64
|
+
return target;
|
|
65
|
+
};
|
|
66
|
+
/** A group echoes many results at once, so each is capped; a single-run
|
|
67
|
+
* `recover` stays whole — it is the escape hatch every truncation note points at. */
|
|
68
|
+
const trimResult = (summary) => {
|
|
69
|
+
if (summary.result?.type !== "agent" || summary.result.text.length <= 2000)
|
|
70
|
+
return summary;
|
|
71
|
+
return {
|
|
72
|
+
...summary,
|
|
73
|
+
result: {
|
|
74
|
+
...summary.result,
|
|
75
|
+
text: `${summary.result.text.slice(0, 2000)}\n[truncated — pier task recover --run ${summary.runId} --reason … for the full text]`,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
/** Delivered, given up on and reported, or never owed (`callback:"none"`).
|
|
80
|
+
* Anything else is still on its way, and reading it here would be reading it twice. */
|
|
81
|
+
const settled = (callback) => callback.callbackState === null || callback.callbackState === "delivered" || callback.callbackState === "abandoned";
|
|
82
|
+
/** The same words for queued, running, pending and retrying: a refusal that
|
|
83
|
+
* named the state would be the status query this surface refuses. */
|
|
84
|
+
const notRecoverable = (what, callback) => {
|
|
85
|
+
throw new Error(callback.callbackSessionId === null
|
|
86
|
+
? `${what} is not recoverable yet: it was launched with callback none, so nothing will be delivered; recover reads finished results only and cannot wait for work`
|
|
87
|
+
: `${what} is not recoverable yet: wait for automatic delivery to session ${callback.callbackSessionId}; recover cannot wait for work`);
|
|
88
|
+
};
|
|
89
|
+
const summarizeGroup = (group, members) => defined({
|
|
90
|
+
groupId: group.id,
|
|
91
|
+
join: group.join,
|
|
92
|
+
state: group.finishedAt ? "finished" : "running",
|
|
93
|
+
callbackState: group.callbackState,
|
|
94
|
+
callbackMode: group.callbackMode ?? null,
|
|
95
|
+
winnerRunId: group.winnerRunId,
|
|
96
|
+
members: members.map((run) => trimResult(summarize(run))),
|
|
97
|
+
next: null,
|
|
98
|
+
});
|
|
99
|
+
const CANCEL_WAIT_MS = 2000;
|
|
100
|
+
/** The run once terminal, or as it stands after `ms`. */
|
|
101
|
+
function cancelledOrCurrent(host, id, ms) {
|
|
102
|
+
return Promise.race([
|
|
103
|
+
host.waitForRun(id),
|
|
104
|
+
new Promise((resolve) => setTimeout(() => resolve(host.getRun(id)), ms).unref()),
|
|
105
|
+
]);
|
|
106
|
+
}
|
|
107
|
+
export async function handleTask(host, definitions, store, raw, callerSessionId) {
|
|
108
|
+
const input = record(raw);
|
|
109
|
+
if (!input)
|
|
110
|
+
throw new Error("task parameters required");
|
|
111
|
+
// Delegation is one level (docs/design/09-tasks-cli.md §Two levels, no tree):
|
|
112
|
+
// what a supervised run launched would report to a session no run owns. A
|
|
113
|
+
// queued run has not taken the session's turn, so it gates nothing yet.
|
|
114
|
+
const active = store.findActiveRunForTarget(callerSessionId);
|
|
115
|
+
if (active?.state === "running" && store.supervised(active))
|
|
116
|
+
throw new Error("a delegated run cannot delegate; ask in your result and let your supervisor run it");
|
|
117
|
+
const menu = () => host.models().then((listed) => listed.models);
|
|
118
|
+
if (input.operation === "list")
|
|
119
|
+
return definitions.list().filter((task) => task.kind !== "subagent");
|
|
120
|
+
if (input.operation === "save") {
|
|
121
|
+
const draft = await expandDraft(definitions, menu, input.task, callerSessionId);
|
|
122
|
+
if (input.task_id === undefined)
|
|
123
|
+
return definitions.create(draft, `session:${callerSessionId}`);
|
|
124
|
+
const id = requiredString(input.task_id, "task_id");
|
|
125
|
+
// A one-shot's hidden definition is a run's record, not a task anyone filed.
|
|
126
|
+
if (definitions.get(id).kind === "subagent")
|
|
127
|
+
throw new Error(`${id} is a one-shot run's own definition; save without --task-id to file a task`);
|
|
128
|
+
return definitions.update(id, draft);
|
|
129
|
+
}
|
|
130
|
+
if (input.operation === "run") {
|
|
131
|
+
// `--model ?`: the menu instead of a run, the one lookup the common case never pays.
|
|
132
|
+
if (record(input.launch)?.model === "?")
|
|
133
|
+
return host.models();
|
|
134
|
+
const callbackMode = callbackModeOf(input);
|
|
135
|
+
if (Array.isArray(input.tasks)) {
|
|
136
|
+
// Core-joined fan-out: members run detached, one aggregated callback.
|
|
137
|
+
if (input.task !== undefined || input.task_id !== undefined)
|
|
138
|
+
throw new Error("use either task/task_id or tasks[]");
|
|
139
|
+
if (input.callback_session_id !== undefined)
|
|
140
|
+
throw new Error("callback_session_id applies to a single run only");
|
|
141
|
+
if (input.tasks.length < 2)
|
|
142
|
+
throw new Error("tasks[] needs at least 2 entries; use task for a single run");
|
|
143
|
+
const resolved = [];
|
|
144
|
+
for (const rawEntry of input.tasks) {
|
|
145
|
+
const entry = typeof rawEntry === "string" ? { prompt: rawEntry } : record(rawEntry);
|
|
146
|
+
if (!entry)
|
|
147
|
+
throw new Error("invalid tasks[] entry");
|
|
148
|
+
resolved.push(entry.task_id === undefined
|
|
149
|
+
? await resolveDraft(definitions, menu, entry, callerSessionId)
|
|
150
|
+
: definitions.get(requiredString(entry.task_id, "task_id")));
|
|
151
|
+
}
|
|
152
|
+
const groupCallbackSessionId = input.callback === "none" ? null : callerSessionId;
|
|
153
|
+
const { group, runs } = host.runGroup(resolved, oneOf(input, "join", ["all", "first"]) ?? "all", callerSessionId, groupCallbackSessionId, callbackMode);
|
|
154
|
+
return receipt(summarizeGroup(group, runs), groupCallbackSessionId, callbackMode, callerSessionId);
|
|
155
|
+
}
|
|
156
|
+
const draft = input.task_id === undefined ? inlineDraft(input) : undefined;
|
|
157
|
+
const task = draft
|
|
158
|
+
? await resolveDraft(definitions, menu, draft, callerSessionId)
|
|
159
|
+
: definitions.get(requiredString(input.task_id, "task_id"));
|
|
160
|
+
const callbackSessionId = await callbackTarget(input, definitions, callerSessionId);
|
|
161
|
+
const run = host.run(task.id, null, "agent", null, {
|
|
162
|
+
invokedBySessionId: callerSessionId,
|
|
163
|
+
sourceSessionId: callerSessionId,
|
|
164
|
+
callbackSessionId,
|
|
165
|
+
callbackMode,
|
|
166
|
+
background: true,
|
|
167
|
+
});
|
|
168
|
+
return receipt(summarize(run), callbackSessionId, callbackMode, callerSessionId);
|
|
169
|
+
}
|
|
170
|
+
if (input.operation === "recover") {
|
|
171
|
+
// History only, never status: readable once the callback has said its last
|
|
172
|
+
// word. The required reason is the friction, and the operator sees it.
|
|
173
|
+
const reason = requiredString(input.reason, "reason");
|
|
174
|
+
// A race winner need not wait for losing members to finish cancelling.
|
|
175
|
+
const groupReady = (group) => {
|
|
176
|
+
if (!group.finishedAt || !settled(group))
|
|
177
|
+
notRecoverable(`group ${group.id}`, group);
|
|
178
|
+
};
|
|
179
|
+
if (typeof input.group_id === "string") {
|
|
180
|
+
const { group, members } = host.getGroup(input.group_id);
|
|
181
|
+
groupReady(group);
|
|
182
|
+
if (!members.every((run) => isTerminal(run.state))) {
|
|
183
|
+
throw new Error(`recover cannot inspect active members; the race has settled — recover its winning result with --run ${group.winnerRunId ?? "<id from the callback>"} and a reason`);
|
|
184
|
+
}
|
|
185
|
+
log.info(`recover group ${group.id} by ${callerSessionId}: ${reason}`);
|
|
186
|
+
return summarizeGroup(group, members);
|
|
187
|
+
}
|
|
188
|
+
const run = host.getRun(requiredString(input.run_id, "run_id"));
|
|
189
|
+
if (run.groupId) {
|
|
190
|
+
const { group } = host.getGroup(run.groupId);
|
|
191
|
+
groupReady(group);
|
|
192
|
+
if (!isTerminal(run.state))
|
|
193
|
+
throw new Error("the group has reported its outcome; recover reads finished results only and cannot wait for this member");
|
|
194
|
+
}
|
|
195
|
+
else if (!isTerminal(run.state) || !settled(run)) {
|
|
196
|
+
notRecoverable(`run ${run.id}`, run);
|
|
197
|
+
}
|
|
198
|
+
log.info(`recover run ${run.id} by ${callerSessionId}: ${reason}`);
|
|
199
|
+
return summarize(run);
|
|
200
|
+
}
|
|
201
|
+
if (input.operation === "cancel") {
|
|
202
|
+
if (typeof input.group_id === "string") {
|
|
203
|
+
const { group, members } = host.getGroup(input.group_id);
|
|
204
|
+
for (const member of members)
|
|
205
|
+
assertOwns(callerSessionId, member);
|
|
206
|
+
const cancelled = host.cancelGroup(group.id);
|
|
207
|
+
return summarizeGroup(cancelled, cancelled.memberRunIds.map((id) => host.getRun(id)));
|
|
208
|
+
}
|
|
209
|
+
const run = host.getRun(requiredString(input.run_id, "run_id"));
|
|
210
|
+
assertOwns(callerSessionId, run);
|
|
211
|
+
// cancel() only aborts; the row flips ~100 ms later. A receipt still saying
|
|
212
|
+
// `running` reads as "cancel failed", so wait for the flip, briefly.
|
|
213
|
+
host.cancel(run.id);
|
|
214
|
+
return summarize(await cancelledOrCurrent(host, run.id, CANCEL_WAIT_MS));
|
|
215
|
+
}
|
|
216
|
+
if (input.operation === "message") {
|
|
217
|
+
// The one request `pier task run --run` sends: the run's state, not the
|
|
218
|
+
// caller, decides whether the text steers, queues or resumes, so a status
|
|
219
|
+
// query never has to exist.
|
|
220
|
+
const run = host.getRun(requiredString(input.run_id, "run_id"));
|
|
221
|
+
assertOwns(callerSessionId, run);
|
|
222
|
+
const message = requiredString(input.message, "message");
|
|
223
|
+
if (isTerminal(run.state)) {
|
|
224
|
+
// A resumed run is a new run with its own callback.
|
|
225
|
+
const callbackMode = callbackModeOf(input);
|
|
226
|
+
const callbackSessionId = await callbackTarget(input, definitions, callerSessionId);
|
|
227
|
+
const resumed = host.resume(run.id, message, { invokedBySessionId: callerSessionId, callbackSessionId, callbackMode, background: true });
|
|
228
|
+
return { delivery: "resume", run: receipt(summarize(resumed), callbackSessionId, callbackMode, callerSessionId) };
|
|
229
|
+
}
|
|
230
|
+
if (input.callback !== undefined || input.callback_session_id !== undefined) {
|
|
231
|
+
throw new Error(`run ${run.id} is ${run.state}: callback options apply to a resumed run only; drop them to steer or follow up`);
|
|
232
|
+
}
|
|
233
|
+
const delivery = input.after === true ? "follow_up" : "steer";
|
|
234
|
+
return { delivery, message: await host.control(run.id, callerSessionId, delivery, message) };
|
|
235
|
+
}
|
|
236
|
+
throw new Error("unknown task operation");
|
|
237
|
+
}
|
|
238
|
+
/** A single run's draft: the top-level shorthand (`prompt` …) or `task`, never both. */
|
|
239
|
+
function inlineDraft(input) {
|
|
240
|
+
const { prompt, cwd, launch, name, timeoutSeconds } = input;
|
|
241
|
+
if (prompt === undefined)
|
|
242
|
+
return record(input.task) ?? undefined;
|
|
243
|
+
if (input.task !== undefined)
|
|
244
|
+
throw new Error("use either prompt or task");
|
|
245
|
+
return { prompt, cwd, launch, name, timeoutSeconds };
|
|
246
|
+
}
|
|
247
|
+
/** A label for the Console, not an identifier. */
|
|
248
|
+
function nameFromPrompt(prompt) {
|
|
249
|
+
const line = prompt.split("\n")
|
|
250
|
+
.map((l) => l.replace(/^[\s#>*-]+/, "").replace(/[*_`]/g, "").replace(/\s+/g, " ").trim())
|
|
251
|
+
.find(Boolean) ?? "subagent";
|
|
252
|
+
return line.length > 60 ? `${line.slice(0, 59).trimEnd()}…` : line;
|
|
253
|
+
}
|
|
254
|
+
/** `launch.model` by name (docs/design/09-tasks-cli.md §Models). Anything but
|
|
255
|
+
* one pin or an unpinned `provider/id` is refused with the lines to pick
|
|
256
|
+
* from, so the agent never guesses an id. */
|
|
257
|
+
function resolveModel(name, menu) {
|
|
258
|
+
const needle = name.trim().toLowerCase();
|
|
259
|
+
const full = (pin) => `${pin.provider}/${pin.id}`;
|
|
260
|
+
const line = (pin) => `${full(pin)}${pin.thinking ? ` · ${pin.thinking}` : ""}${pin.note ? ` — ${pin.note}` : ""}`;
|
|
261
|
+
const exact = menu.find((pin) => full(pin).toLowerCase() === needle);
|
|
262
|
+
const hits = exact ? [exact] : menu.filter((pin) => `${full(pin)} ${pin.note ?? ""}`.toLowerCase().includes(needle));
|
|
263
|
+
if (hits.length === 1)
|
|
264
|
+
return { model: { provider: hits[0].provider, id: hits[0].id }, thinking: hits[0].thinking };
|
|
265
|
+
const slash = name.indexOf("/");
|
|
266
|
+
if (!hits.length && slash > 0 && slash < name.length - 1)
|
|
267
|
+
return { model: { provider: name.slice(0, slash), id: name.slice(slash + 1) } };
|
|
268
|
+
const lines = (hits.length ? hits : menu).map(line).join("\n") || "(no model is pinned or available)";
|
|
269
|
+
throw new Error(`model "${name}" matches ${String(hits.length)} of the menu:\n${lines}`);
|
|
270
|
+
}
|
|
271
|
+
/** A `prompt` shorthand becomes a fresh Agent action in the caller's own
|
|
272
|
+
* directory; everything the caller did spell out passes through to parseDraft. */
|
|
273
|
+
async function expandDraft(definitions, menu, raw, callerSessionId) {
|
|
274
|
+
let draft = record(raw);
|
|
275
|
+
if (!draft)
|
|
276
|
+
return raw;
|
|
277
|
+
if (typeof draft.prompt === "string") {
|
|
278
|
+
if (draft.action !== undefined)
|
|
279
|
+
throw new Error("use either prompt (shorthand) or action");
|
|
280
|
+
const { prompt, cwd, launch, name, ...rest } = draft;
|
|
281
|
+
draft = { ...rest, name, action: { type: "agent", session: { mode: "fresh", cwd }, prompt, launch } };
|
|
282
|
+
}
|
|
283
|
+
const action = record(draft.action);
|
|
284
|
+
const session = record(action?.session);
|
|
285
|
+
if (action?.type === "agent" && session?.mode === "fresh" && (session.cwd === undefined || (typeof session.cwd === "string" && !isAbsolute(session.cwd)))) {
|
|
286
|
+
const base = await definitions.sessionCwd(callerSessionId);
|
|
287
|
+
if (!base)
|
|
288
|
+
throw new Error(`cwd ${session.cwd === undefined ? "omitted" : `"${session.cwd}" is relative`} and the calling session has no working directory; give an absolute path`);
|
|
289
|
+
draft = { ...draft, action: { ...action, session: { ...session, cwd: resolve(base, session.cwd ?? ".") } } };
|
|
290
|
+
}
|
|
291
|
+
if (draft.name === undefined && typeof action?.prompt === "string")
|
|
292
|
+
draft = { ...draft, name: nameFromPrompt(action.prompt) };
|
|
293
|
+
const launch = record(action?.launch);
|
|
294
|
+
if (typeof launch?.model === "string") {
|
|
295
|
+
const { model, thinking } = resolveModel(launch.model, await menu());
|
|
296
|
+
const resolved = { ...launch, model, ...(launch.thinking === undefined && thinking ? { thinking } : {}) };
|
|
297
|
+
draft = { ...draft, action: { ...record(draft.action), launch: resolved } };
|
|
298
|
+
}
|
|
299
|
+
return draft;
|
|
300
|
+
}
|
|
301
|
+
/** Persisted like any task (kind "subagent", filtered from default lists) so
|
|
302
|
+
* runs stay auditable and resumable. */
|
|
303
|
+
async function resolveDraft(definitions, menu, raw, callerSessionId) {
|
|
304
|
+
const draft = record(await expandDraft(definitions, menu, raw, callerSessionId));
|
|
305
|
+
if (!draft)
|
|
306
|
+
throw new Error("task definition required");
|
|
307
|
+
if (draft.trigger !== undefined && record(draft.trigger)?.type !== "manual") {
|
|
308
|
+
throw new Error("inline subagent tasks must use a manual trigger");
|
|
309
|
+
}
|
|
310
|
+
// Delivery of a one-off run is the top-level fields' business; a nested
|
|
311
|
+
// callback only means anything on a stored definition's schedule.
|
|
312
|
+
if (draft.callback !== undefined || draft.callback_session_id !== undefined) {
|
|
313
|
+
throw new Error("an inline task draft cannot set callback; use the top-level callback / callback_session_id");
|
|
314
|
+
}
|
|
315
|
+
return definitions.create({ ...draft, trigger: { type: "manual" } }, `session:${callerSessionId}`, "subagent");
|
|
316
|
+
}
|
|
317
|
+
/** The session that launched a run controls it, and so does the run's own
|
|
318
|
+
* session; a `task` action's child inherits its parent's launcher. */
|
|
319
|
+
function assertOwns(callerSessionId, target) {
|
|
320
|
+
if (target.invokedBySessionId !== callerSessionId && target.targetSessionId !== callerSessionId) {
|
|
321
|
+
throw new Error("session does not own this run");
|
|
322
|
+
}
|
|
323
|
+
}
|
package/dist/tasks/routes.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The area's HTTP surface. A route names the caller and hands the decision to
|
|
2
|
-
// TaskService: policy here would be policy
|
|
2
|
+
// TaskService: policy here would be policy `pier task` does not get.
|
|
3
3
|
import { record, requiredString } from "./definitions.js";
|
|
4
4
|
/** Reject malformed filters instead of silently widening a global query. */
|
|
5
5
|
function runQuery(params) {
|
|
@@ -184,16 +184,6 @@ export function registerTaskRoutes(app, tasks, activity) {
|
|
|
184
184
|
return c.json({ error: String(err) }, 400);
|
|
185
185
|
}
|
|
186
186
|
});
|
|
187
|
-
app.post("/api/task-messages/:id/reply", async (c) => {
|
|
188
|
-
const body = record(await jsonBody(c.req));
|
|
189
|
-
try {
|
|
190
|
-
const source = requiredString(body?.sourceSessionId, "sourceSessionId");
|
|
191
|
-
return c.json(await tasks.reply(c.req.param("id"), source, requiredString(body?.message, "message")), 202);
|
|
192
|
-
}
|
|
193
|
-
catch (err) {
|
|
194
|
-
return c.json({ error: String(err) }, 400);
|
|
195
|
-
}
|
|
196
|
-
});
|
|
197
187
|
app.post("/api/task-runs/:id/cancel", (c) => {
|
|
198
188
|
try {
|
|
199
189
|
return c.json(tasks.cancel(c.req.param("id")), 202);
|