@timqi/pier 0.0.28 → 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.
- package/dist/agent/config-sync.js +12 -1
- package/dist/agent/config.js +53 -23
- package/dist/agent/events.js +27 -4
- package/dist/agent/listing.js +114 -16
- package/dist/agent/pi.js +158 -16
- package/dist/channels/lark-panel.js +1 -1
- package/dist/core/inbox.js +1 -1
- package/dist/core/router.js +14 -0
- package/dist/core/types.js +13 -1
- package/dist/db.js +48 -0
- package/dist/drain.js +1 -1
- package/dist/extensions/web/content.js +2 -2
- package/dist/main.js +8 -1
- package/dist/secrets.js +1 -1
- package/dist/service.js +2 -2
- package/dist/settings.js +31 -10
- package/dist/tasks/agent.js +109 -73
- package/dist/tasks/callbacks.js +1 -3
- package/dist/tasks/command.js +27 -8
- package/dist/tasks/execution.js +6 -4
- package/dist/tasks/groups.js +35 -26
- package/dist/tasks/messages.js +33 -27
- package/dist/tasks/outbox.js +48 -21
- package/dist/tasks/routes.js +1 -4
- package/dist/tasks/runs.js +10 -4
- package/dist/tasks/service.js +24 -12
- package/dist/tasks/store.js +17 -3
- package/dist/tasks/tool.js +97 -25
- package/dist/tools.js +2 -2
- package/dist/update.js +1 -1
- package/dist/web/instance.js +10 -3
- package/dist/web/providers.js +9 -2
- package/dist/web/public/assets/{activity-DVamP8fs.js → activity-Bl3vZukb.js} +2 -2
- package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
- package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js +1 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js +4 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js +85 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css +2 -0
- package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js +1 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js +5 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js +3 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js +4 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
- package/dist/web/public/index.html +103 -134
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest +2 -2
- package/dist/web/public/manifest.webmanifest.br +0 -0
- package/dist/web/public/manifest.webmanifest.gz +0 -0
- package/dist/web/server.js +55 -36
- package/dist/web/session-state.js +54 -33
- package/package.json +2 -1
- package/skills/pier-tasks/SKILL.md +146 -127
- package/dist/web/public/assets/activity-DVamP8fs.js.br +0 -0
- package/dist/web/public/assets/activity-DVamP8fs.js.gz +0 -0
- package/dist/web/public/assets/boards-BKtjPwus.js +0 -1
- package/dist/web/public/assets/boards-BKtjPwus.js.br +0 -0
- package/dist/web/public/assets/boards-BKtjPwus.js.gz +0 -0
- package/dist/web/public/assets/explorer-CjO9NAKQ.js +0 -4
- package/dist/web/public/assets/explorer-CjO9NAKQ.js.br +0 -0
- package/dist/web/public/assets/explorer-CjO9NAKQ.js.gz +0 -0
- package/dist/web/public/assets/index-C23sXyS3.css +0 -2
- package/dist/web/public/assets/index-C23sXyS3.css.br +0 -0
- package/dist/web/public/assets/index-C23sXyS3.css.gz +0 -0
- package/dist/web/public/assets/index-DzWnvhXb.js +0 -85
- package/dist/web/public/assets/index-DzWnvhXb.js.br +0 -0
- package/dist/web/public/assets/index-DzWnvhXb.js.gz +0 -0
- package/dist/web/public/assets/runs-CxIZAx1s.js +0 -1
- package/dist/web/public/assets/runs-CxIZAx1s.js.br +0 -0
- package/dist/web/public/assets/runs-CxIZAx1s.js.gz +0 -0
- package/dist/web/public/assets/settings-B8JWLbpL.js +0 -5
- package/dist/web/public/assets/settings-B8JWLbpL.js.br +0 -0
- package/dist/web/public/assets/settings-B8JWLbpL.js.gz +0 -0
- package/dist/web/public/assets/task-runs-BZ1brkLC.js +0 -3
- package/dist/web/public/assets/task-runs-BZ1brkLC.js.br +0 -0
- package/dist/web/public/assets/task-runs-BZ1brkLC.js.gz +0 -0
- package/dist/web/public/assets/tasks-BoYiBQpq.js +0 -4
- package/dist/web/public/assets/tasks-BoYiBQpq.js.br +0 -0
- package/dist/web/public/assets/tasks-BoYiBQpq.js.gz +0 -0
package/dist/tasks/agent.js
CHANGED
|
@@ -4,11 +4,10 @@
|
|
|
4
4
|
// agents specifically — a bash run costs a process, an agent run costs a
|
|
5
5
|
// model's context and someone's rate limit.
|
|
6
6
|
import { quietLabel, splitReply } from "../core/reply.js";
|
|
7
|
-
import {
|
|
7
|
+
import { logger } from "../log.js";
|
|
8
8
|
import { runSource } from "./callbacks.js";
|
|
9
|
-
import { TaskMessenger } from "./messages.js";
|
|
10
|
-
import { TaskStore } from "./store.js";
|
|
11
9
|
const MAX_ACTIVE_AGENTS = 4;
|
|
10
|
+
const log = logger("tasks");
|
|
12
11
|
/** What a child cannot know unless told. Every session gets the chat-surface
|
|
13
12
|
* contract (<pier>/AGENTS.md), task runs included — so the delegation prompt
|
|
14
13
|
* says which of it does not apply here, and a supervised run how to reach the
|
|
@@ -44,11 +43,18 @@ export class AgentTaskRunner {
|
|
|
44
43
|
this.changed = changed;
|
|
45
44
|
}
|
|
46
45
|
async execute(run, action, signal, start) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
// Reserve reuse order before resolving the session: a slow resume must not
|
|
47
|
+
// let a later run pass it. Fresh runs each have their own queue key.
|
|
48
|
+
return this.withSession(run.targetSessionId ?? run.id, signal, async () => {
|
|
49
|
+
const reused = run.targetSessionId ? await this.resolveSession(run, action, signal) : undefined;
|
|
50
|
+
if (reused)
|
|
51
|
+
await this.waitUntilIdle(reused, signal);
|
|
52
|
+
await this.acquireSlot(run, signal);
|
|
53
|
+
try {
|
|
54
|
+
const session = reused ?? await this.resolveSession(run, action, signal);
|
|
55
|
+
// A reused session may have become busy while we waited for a slot.
|
|
51
56
|
await this.waitUntilIdle(session, signal);
|
|
57
|
+
signal.throwIfAborted();
|
|
52
58
|
// Task requests come seconds apart — the 1h Anthropic cache-write premium
|
|
53
59
|
// never earns its 2× back, so task runs use the 5m TTL. Set here, not in
|
|
54
60
|
// resolveSession: this covers create, reuse and resume alike, on
|
|
@@ -85,22 +91,16 @@ export class AgentTaskRunner {
|
|
|
85
91
|
failure = event.error;
|
|
86
92
|
}
|
|
87
93
|
});
|
|
88
|
-
//
|
|
89
|
-
// a hung turn must not hold one of the 4 slots (and its waiters)
|
|
90
|
-
// forever — the same guard execution.ts gives task-type children.
|
|
91
|
-
let rejectAborted = () => { };
|
|
92
|
-
const abortedTurn = new Promise((_, reject) => { rejectAborted = reject; });
|
|
93
|
-
abortedTurn.catch(() => { }); // handled via the race; never unhandled
|
|
94
|
+
// Cancel the SDK turn, but release our wait even if its abort hangs.
|
|
94
95
|
const abort = () => {
|
|
95
|
-
void session.abort()
|
|
96
|
-
|
|
96
|
+
void Promise.resolve().then(() => session.abort())
|
|
97
|
+
.catch((error) => log.warn(`run ${run.id} session abort failed`, error));
|
|
97
98
|
};
|
|
98
99
|
signal.addEventListener("abort", abort, { once: true });
|
|
99
|
-
// A pre-aborted signal never fires the listener: check before the
|
|
100
|
-
// prompt starts or a cancelled run would hang until its timeout.
|
|
101
|
-
if (signal.aborted)
|
|
102
|
-
throw new Error("cancelled");
|
|
103
100
|
try {
|
|
101
|
+
// Pre-abort does not fire a newly registered listener; keep the check
|
|
102
|
+
// inside finally's scope so it also restores retention/subscriptions.
|
|
103
|
+
signal.throwIfAborted();
|
|
104
104
|
const turn = session.systemInput(prompt, {
|
|
105
105
|
kind: "task-delegation",
|
|
106
106
|
taskId: run.taskId,
|
|
@@ -110,7 +110,7 @@ export class AgentTaskRunner {
|
|
|
110
110
|
}, "prompt");
|
|
111
111
|
await Promise.resolve();
|
|
112
112
|
this.messages.deliverPendingControls(run);
|
|
113
|
-
await
|
|
113
|
+
await this.untilAborted(turn, signal);
|
|
114
114
|
if (signal.aborted)
|
|
115
115
|
throw new Error("cancelled");
|
|
116
116
|
// Before the fallback below: on a reused session that reads the
|
|
@@ -119,7 +119,7 @@ export class AgentTaskRunner {
|
|
|
119
119
|
if (failure)
|
|
120
120
|
throw new Error(failure);
|
|
121
121
|
if (!text) {
|
|
122
|
-
const history = await session.history();
|
|
122
|
+
const history = await this.untilAborted(session.history(), signal);
|
|
123
123
|
text = [...history].reverse().find((turn) => turn.role === "assistant")?.text ?? "";
|
|
124
124
|
}
|
|
125
125
|
// The chat contract is injected into task sessions too, so a child's
|
|
@@ -138,15 +138,16 @@ export class AgentTaskRunner {
|
|
|
138
138
|
signal.removeEventListener("abort", abort);
|
|
139
139
|
unsubscribe();
|
|
140
140
|
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
this.releaseSlot(run.id);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
146
|
}
|
|
147
|
-
async resolveSession(run, action) {
|
|
147
|
+
async resolveSession(run, action, signal) {
|
|
148
|
+
signal.throwIfAborted();
|
|
148
149
|
if (run.targetSessionId) {
|
|
149
|
-
return this.router.ensure({ channelId: "task", conversationId: run.targetSessionId });
|
|
150
|
+
return this.untilAborted(this.router.ensure({ channelId: "task", conversationId: run.targetSessionId }), signal);
|
|
150
151
|
}
|
|
151
152
|
const policy = action.session;
|
|
152
153
|
// Definitions stored before fork was removed still say `"fork"`. Refused by
|
|
@@ -157,7 +158,8 @@ export class AgentTaskRunner {
|
|
|
157
158
|
}
|
|
158
159
|
const cwd = policy.mode === "fresh"
|
|
159
160
|
? policy.cwd
|
|
160
|
-
: (await this.factory.find(policy.sessionId))?.cwd ?? "";
|
|
161
|
+
: (await this.untilAborted(this.factory.find(policy.sessionId), signal))?.cwd ?? "";
|
|
162
|
+
signal.throwIfAborted();
|
|
161
163
|
if (!cwd)
|
|
162
164
|
throw new Error("could not resolve child working directory");
|
|
163
165
|
const opts = {
|
|
@@ -169,33 +171,52 @@ export class AgentTaskRunner {
|
|
|
169
171
|
(run.sourceSessionId ? this.router.modelOf(run.sourceSessionId) : undefined),
|
|
170
172
|
thinking: action.launch?.thinking,
|
|
171
173
|
};
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
174
|
+
const opening = this.factory.create(opts).then(async (session) => {
|
|
175
|
+
// SDK creation cannot be cancelled. A late session still belongs to
|
|
176
|
+
// this run; read back its terminal row so delivery updates are preserved.
|
|
177
|
+
// Remember it on the live run too: cancellation's final save can race
|
|
178
|
+
// this callback before it finishes writing the terminal record.
|
|
179
|
+
run.targetSessionId = session.id;
|
|
180
|
+
run.context.sessionId = session.id;
|
|
181
|
+
run.context.cwd = cwd;
|
|
182
|
+
const owner = signal.aborted ? this.store.getRun(run.id) ?? run : run;
|
|
183
|
+
owner.targetSessionId = run.targetSessionId;
|
|
184
|
+
owner.context = run.context;
|
|
185
|
+
try {
|
|
186
|
+
this.store.saveRun(owner);
|
|
187
|
+
this.changed(owner);
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
if (signal.aborted)
|
|
191
|
+
await session.dispose();
|
|
192
|
+
else
|
|
193
|
+
this.router.attach({ channelId: "task", conversationId: session.id }, session);
|
|
194
|
+
}
|
|
195
|
+
return session;
|
|
196
|
+
});
|
|
197
|
+
// A late rejection cannot reach execute's already-cancelled race.
|
|
198
|
+
void opening.catch((error) => {
|
|
199
|
+
if (signal.aborted)
|
|
200
|
+
log.warn(`session creation for cancelled run ${run.id} failed`, error);
|
|
201
|
+
});
|
|
202
|
+
const session = await this.untilAborted(opening, signal);
|
|
203
|
+
signal.throwIfAborted();
|
|
179
204
|
return session;
|
|
180
205
|
}
|
|
181
206
|
async acquireSlot(run, signal) {
|
|
182
207
|
while (this.active.size >= MAX_ACTIVE_AGENTS) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
reject(new Error("cancelled"));
|
|
194
|
-
};
|
|
195
|
-
this.slotWaiters.add(wake);
|
|
196
|
-
signal.addEventListener("abort", abort, { once: true });
|
|
197
|
-
});
|
|
208
|
+
let wake = () => { };
|
|
209
|
+
try {
|
|
210
|
+
await this.untilAborted(new Promise((resolve) => {
|
|
211
|
+
wake = resolve;
|
|
212
|
+
this.slotWaiters.add(wake);
|
|
213
|
+
}), signal);
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
this.slotWaiters.delete(wake);
|
|
217
|
+
}
|
|
198
218
|
}
|
|
219
|
+
signal.throwIfAborted();
|
|
199
220
|
this.active.add(run.id);
|
|
200
221
|
}
|
|
201
222
|
releaseSlot(id) {
|
|
@@ -203,43 +224,58 @@ export class AgentTaskRunner {
|
|
|
203
224
|
for (const wake of this.slotWaiters)
|
|
204
225
|
wake();
|
|
205
226
|
}
|
|
206
|
-
async
|
|
227
|
+
async untilAborted(promise, signal) {
|
|
228
|
+
let abort = () => { };
|
|
229
|
+
const cancelled = new Promise((_, reject) => {
|
|
230
|
+
abort = () => reject(new Error("cancelled"));
|
|
231
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
232
|
+
if (signal.aborted)
|
|
233
|
+
abort();
|
|
234
|
+
});
|
|
235
|
+
try {
|
|
236
|
+
return await Promise.race([promise, cancelled]);
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
signal.removeEventListener("abort", abort);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async withSession(sessionId, signal, fn) {
|
|
243
|
+
signal.throwIfAborted();
|
|
207
244
|
const previous = this.sessionTails.get(sessionId) ?? Promise.resolve();
|
|
208
245
|
let release = () => { };
|
|
209
246
|
const gate = new Promise((resolve) => { release = resolve; });
|
|
210
247
|
const tail = previous.then(() => gate);
|
|
211
248
|
this.sessionTails.set(sessionId, tail);
|
|
212
|
-
|
|
249
|
+
// Even if this waiter is cancelled, its tail still includes its predecessor.
|
|
250
|
+
// Only delete after that tail drains, or a new arrival could skip the queue.
|
|
251
|
+
void tail.then(() => {
|
|
252
|
+
if (this.sessionTails.get(sessionId) === tail)
|
|
253
|
+
this.sessionTails.delete(sessionId);
|
|
254
|
+
});
|
|
213
255
|
try {
|
|
256
|
+
await this.untilAborted(previous, signal);
|
|
257
|
+
signal.throwIfAborted();
|
|
214
258
|
return await fn();
|
|
215
259
|
}
|
|
216
260
|
finally {
|
|
217
261
|
release();
|
|
218
|
-
if (this.sessionTails.get(sessionId) === tail)
|
|
219
|
-
this.sessionTails.delete(sessionId);
|
|
220
262
|
}
|
|
221
263
|
}
|
|
222
264
|
async waitUntilIdle(session, signal) {
|
|
223
265
|
// The session's own event stream is the only busy/idle signal — no polling.
|
|
224
266
|
while (session.state === "streaming") {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const unsubscribe = session.subscribe((event) => {
|
|
238
|
-
if (event.type === "state" && event.state === "idle")
|
|
239
|
-
settle();
|
|
240
|
-
});
|
|
241
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
242
|
-
});
|
|
267
|
+
let unsubscribe = () => { };
|
|
268
|
+
try {
|
|
269
|
+
await this.untilAborted(new Promise((resolve) => {
|
|
270
|
+
unsubscribe = session.subscribe((event) => {
|
|
271
|
+
if (event.type === "state" && event.state === "idle")
|
|
272
|
+
resolve();
|
|
273
|
+
});
|
|
274
|
+
}), signal);
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
unsubscribe();
|
|
278
|
+
}
|
|
243
279
|
}
|
|
244
280
|
}
|
|
245
281
|
}
|
package/dist/tasks/callbacks.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
// What a finished run says to the session that delegated it. Delivery itself
|
|
2
2
|
// belongs to outbox.ts; this file owns the run vocabulary and the batching.
|
|
3
|
-
import { Router } from "../core/router.js";
|
|
4
3
|
import { Outbox } from "./outbox.js";
|
|
5
|
-
import { TaskStore } from "./store.js";
|
|
6
4
|
/** How a run is named to the session that gets its result: the run id, and the
|
|
7
5
|
* session that did the work — a relayer's next move is a deep link to it
|
|
8
6
|
* (`#/session/<id>`), and without this line that costs a second tool call.
|
|
@@ -28,7 +26,7 @@ export function runResultText(run) {
|
|
|
28
26
|
if (run.result?.type === "watch")
|
|
29
27
|
result = "Watch condition did not match";
|
|
30
28
|
if (result.length > 8000)
|
|
31
|
-
result = `${result.slice(0, 8000)}\n[truncated — task tool
|
|
29
|
+
result = `${result.slice(0, 8000)}\n[truncated — task tool recover run_id ${run.id} with a reason returns the full text]`;
|
|
32
30
|
return result;
|
|
33
31
|
}
|
|
34
32
|
export class TaskCallbacks {
|
package/dist/tasks/command.js
CHANGED
|
@@ -5,6 +5,7 @@ import { spawn } from "node:child_process";
|
|
|
5
5
|
import { logger } from "../log.js";
|
|
6
6
|
const log = logger("tasks");
|
|
7
7
|
const OUTPUT_LIMIT = 1024 * 1024;
|
|
8
|
+
const KILL_GRACE_MS = 250;
|
|
8
9
|
class CappedOutput {
|
|
9
10
|
chunks = [];
|
|
10
11
|
bytes = 0;
|
|
@@ -38,34 +39,52 @@ export function runBash(script, cwd, input, signal) {
|
|
|
38
39
|
const stdout = new CappedOutput();
|
|
39
40
|
const stderr = new CappedOutput();
|
|
40
41
|
let settled = false;
|
|
41
|
-
|
|
42
|
+
let killTimer;
|
|
43
|
+
const kill = (signal) => {
|
|
42
44
|
if (!child.pid)
|
|
43
45
|
return;
|
|
44
46
|
try {
|
|
45
47
|
if (process.platform === "win32")
|
|
46
|
-
child.kill(
|
|
48
|
+
child.kill(signal);
|
|
47
49
|
else
|
|
48
|
-
process.kill(-child.pid,
|
|
50
|
+
process.kill(-child.pid, signal);
|
|
49
51
|
}
|
|
50
|
-
catch {
|
|
51
|
-
//
|
|
52
|
+
catch (err) {
|
|
53
|
+
// ESRCH means the owned process group already exited.
|
|
54
|
+
if (err.code !== "ESRCH")
|
|
55
|
+
log.warn(`run ${signal} failed: ${String(err)}`);
|
|
52
56
|
}
|
|
53
57
|
};
|
|
54
|
-
|
|
58
|
+
const abort = () => {
|
|
59
|
+
kill("SIGTERM");
|
|
60
|
+
killTimer = setTimeout(() => kill("SIGKILL"), KILL_GRACE_MS);
|
|
61
|
+
killTimer.unref();
|
|
62
|
+
};
|
|
63
|
+
const cleanup = () => {
|
|
64
|
+
clearTimeout(killTimer);
|
|
65
|
+
signal.removeEventListener("abort", abort);
|
|
66
|
+
};
|
|
67
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
68
|
+
if (signal.aborted)
|
|
69
|
+
abort();
|
|
55
70
|
child.stdout.on("data", (chunk) => stdout.add(chunk));
|
|
56
71
|
child.stderr.on("data", (chunk) => stderr.add(chunk));
|
|
57
72
|
child.on("error", (err) => {
|
|
58
73
|
if (settled)
|
|
59
74
|
return;
|
|
60
75
|
settled = true;
|
|
61
|
-
|
|
76
|
+
cleanup();
|
|
62
77
|
reject(err);
|
|
63
78
|
});
|
|
64
79
|
child.on("close", (code) => {
|
|
65
80
|
if (settled)
|
|
66
81
|
return;
|
|
67
82
|
settled = true;
|
|
68
|
-
|
|
83
|
+
// A TERM trap may exit the shell while a descendant ignores TERM and
|
|
84
|
+
// has redirected its pipes. Closing the shell must not spare that group.
|
|
85
|
+
if (signal.aborted)
|
|
86
|
+
kill("SIGKILL");
|
|
87
|
+
cleanup();
|
|
69
88
|
resolve({
|
|
70
89
|
exitCode: code,
|
|
71
90
|
stdout: stdout.text(),
|
package/dist/tasks/execution.js
CHANGED
|
@@ -3,11 +3,7 @@
|
|
|
3
3
|
// while it goes, settled exactly once. Command and agent work lives in
|
|
4
4
|
// command.ts and agent.ts; this file owns the lifecycle they share.
|
|
5
5
|
import { logger } from "../log.js";
|
|
6
|
-
import { AgentTaskRunner } from "./agent.js";
|
|
7
|
-
import { TaskCallbacks } from "./callbacks.js";
|
|
8
6
|
import { runBash } from "./command.js";
|
|
9
|
-
import { TaskDefinitions } from "./definitions.js";
|
|
10
|
-
import { TaskStore } from "./store.js";
|
|
11
7
|
const log = logger("tasks");
|
|
12
8
|
export class TaskExecution {
|
|
13
9
|
store;
|
|
@@ -40,6 +36,8 @@ export class TaskExecution {
|
|
|
40
36
|
let timedOut = false;
|
|
41
37
|
let cause;
|
|
42
38
|
const timeout = setTimeout(() => {
|
|
39
|
+
if (controller.signal.aborted)
|
|
40
|
+
return;
|
|
43
41
|
timedOut = true;
|
|
44
42
|
controller.abort();
|
|
45
43
|
}, run.context.definition.timeoutSeconds * 1000);
|
|
@@ -58,6 +56,7 @@ export class TaskExecution {
|
|
|
58
56
|
}
|
|
59
57
|
if (run.matched !== false)
|
|
60
58
|
run.result = await this.executeAction(run, controller.signal);
|
|
59
|
+
controller.signal.throwIfAborted();
|
|
61
60
|
run.state = "succeeded";
|
|
62
61
|
if (definition.trigger.type === "watch" && !run.resumedFromRunId && definition.trigger.mode === "once" && run.matched) {
|
|
63
62
|
// As the definition's own creator: a one-shot watch retiring itself is
|
|
@@ -112,6 +111,7 @@ export class TaskExecution {
|
|
|
112
111
|
}
|
|
113
112
|
}
|
|
114
113
|
async executeAction(run, signal) {
|
|
114
|
+
signal.throwIfAborted();
|
|
115
115
|
const action = run.context.definition.action;
|
|
116
116
|
if (action.type === "bash") {
|
|
117
117
|
this.markRunning(run);
|
|
@@ -143,6 +143,8 @@ export class TaskExecution {
|
|
|
143
143
|
rejectWait(new Error("cancelled"));
|
|
144
144
|
};
|
|
145
145
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
146
|
+
if (signal.aborted)
|
|
147
|
+
onAbort();
|
|
146
148
|
try {
|
|
147
149
|
const done = await Promise.race([this.host.waitForRun(child.id), aborted]);
|
|
148
150
|
if (done.state !== "succeeded")
|
package/dist/tasks/groups.js
CHANGED
|
@@ -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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
this.
|
|
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
|
-
|
|
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));
|
package/dist/tasks/messages.js
CHANGED
|
@@ -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
|
-
|
|
25
|
+
prepareResume;
|
|
29
26
|
unreachable;
|
|
27
|
+
startRun;
|
|
30
28
|
constructor(store, router, hub,
|
|
31
|
-
/**
|
|
32
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
}
|