@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.
- package/dist/agent/config.js +22 -0
- package/dist/agent/events.js +4 -1
- 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 +4 -0
- package/dist/core/types.js +13 -1
- package/dist/db.js +30 -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-D3m4L2IL.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 +76 -120
- 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 +40 -8
- package/dist/web/session-state.js +5 -5
- package/package.json +2 -1
- package/skills/pier-tasks/SKILL.md +145 -160
- package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
- package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
- package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
- package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
- package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
- package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
- package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
- package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
- package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
- package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
- package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
- package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
- package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
- package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
- package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
- package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
- package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
- package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
- package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
- package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
- package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
- package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
- package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
- package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
- package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
- package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
package/dist/db.js
CHANGED
|
@@ -349,6 +349,36 @@ const MIGRATIONS = [
|
|
|
349
349
|
ORDER BY sort, session_id LIMIT -1 OFFSET 8
|
|
350
350
|
);
|
|
351
351
|
ALTER TABLE session_state DROP COLUMN pinned;
|
|
352
|
+
`,
|
|
353
|
+
// 21 — unread is the workbench's own attention, so it is only its own rows.
|
|
354
|
+
`
|
|
355
|
+
-- The flag was written for every session whose turn ended, including the
|
|
356
|
+
-- ones no browser is the reader of: an IM session answers its chat, a run's
|
|
357
|
+
-- session answers its supervisor. Both are now skipped at the write
|
|
358
|
+
-- (web/server.ts), and neither could ever be acked — that needs the session
|
|
359
|
+
-- on screen — so the rows they left would stay set forever. On the instance
|
|
360
|
+
-- this was decided on, 195 of 196 marks were those. Cleared wholesale rather
|
|
361
|
+
-- than by owner: the one real row is a turn from before an upgrade nobody
|
|
362
|
+
-- was watching for, and a false amber dot costs less than the join.
|
|
363
|
+
UPDATE session_state SET unread = 0;
|
|
364
|
+
`,
|
|
365
|
+
// 22 — the palette finds what was said, not only what a session is called.
|
|
366
|
+
`
|
|
367
|
+
-- One row per user message and per assistant reply, keyed by the transcript
|
|
368
|
+
-- it came from so a file that is rewritten or gone drops its rows in one
|
|
369
|
+
-- statement (agent/listing.ts). Trigram tokens: a substring match with no
|
|
370
|
+
-- word segmentation, which CJK text has none of and a path or an identifier
|
|
371
|
+
-- in a prompt has too much of. Steps stay out — tool calls, their output,
|
|
372
|
+
-- thinking are the bulk of a transcript and nobody searches for what ran.
|
|
373
|
+
CREATE VIRTUAL TABLE session_fts USING fts5(
|
|
374
|
+
text, session_id UNINDEXED, path UNINDEXED, role UNINDEXED, at UNINDEXED,
|
|
375
|
+
tokenize = 'trigram'
|
|
376
|
+
);
|
|
377
|
+
-- Derived and disposable (migration 7): every row goes, so the next scan
|
|
378
|
+
-- reads every transcript from its first byte and fills the table above.
|
|
379
|
+
-- Resetting parsed_bytes alone would not — a row whose (size, mtime) still
|
|
380
|
+
-- match is trusted without opening the file.
|
|
381
|
+
DELETE FROM session_index;
|
|
352
382
|
`,
|
|
353
383
|
];
|
|
354
384
|
/**
|
package/dist/drain.js
CHANGED
|
@@ -11,7 +11,7 @@ import { logger } from "./log.js";
|
|
|
11
11
|
const log = logger("drain");
|
|
12
12
|
/** How long running turns may take before they are aborted. Generous: a turn
|
|
13
13
|
* can be a subagent fan-out, and an abort still persists the partial work. */
|
|
14
|
-
|
|
14
|
+
const DRAIN_DEADLINE_MS = 5 * 60_000;
|
|
15
15
|
const POLL_MS = 1_000;
|
|
16
16
|
/** Shared cleanup window after the deadline. All sessions use the same clock,
|
|
17
17
|
* so N hung seams still cost at most this long rather than N times as long. */
|
|
@@ -49,7 +49,7 @@ export function sourcesFrom(content) {
|
|
|
49
49
|
return [...sources.values()].map(({ title, url }) => ({ title, url }));
|
|
50
50
|
}
|
|
51
51
|
/** Only what the search itself returned, in the order it ranked them. */
|
|
52
|
-
|
|
52
|
+
function searchResultsFrom(content) {
|
|
53
53
|
const results = new Map();
|
|
54
54
|
for (const block of content) {
|
|
55
55
|
if (!isObject(block) || block.type !== "web_search_tool_result")
|
|
@@ -61,7 +61,7 @@ export function searchResultsFrom(content) {
|
|
|
61
61
|
}
|
|
62
62
|
return [...results.values()];
|
|
63
63
|
}
|
|
64
|
-
|
|
64
|
+
function searchQueriesFrom(content) {
|
|
65
65
|
const queries = [];
|
|
66
66
|
for (const block of content) {
|
|
67
67
|
if (!isObject(block) || block.type !== "server_tool_use" || block.name !== "web_search") {
|
package/dist/main.js
CHANGED
|
@@ -8,6 +8,7 @@ import { normalizeAgentSnapshot } from "./agent/config-sync.js";
|
|
|
8
8
|
import { ConfigSync } from "./config-sync.js";
|
|
9
9
|
import { configSyncTask } from "./config-sync-task.js";
|
|
10
10
|
import { CredentialStore } from "./agent/credentials.js";
|
|
11
|
+
import { IndexedListing } from "./agent/listing.js";
|
|
11
12
|
import { PiAgentFactory } from "./agent/pi.js";
|
|
12
13
|
import { defaultBoardsDir, registerBoardRoutes } from "./boards/boards.js";
|
|
13
14
|
import { ChannelStore } from "./channels/config.js";
|
|
@@ -20,6 +21,7 @@ import { SlackDirectory } from "./channels/slack-directory.js";
|
|
|
20
21
|
import { handleSlackTool, slackToolAvailable, slackToolSpec } from "./channels/slack-tool.js";
|
|
21
22
|
import { parseConversation as parseSlackConversation } from "./channels/slack.js";
|
|
22
23
|
import { EventHub } from "./core/hub.js";
|
|
24
|
+
import { splitSpeaker } from "./core/identity.js";
|
|
23
25
|
import { pierDb } from "./db.js";
|
|
24
26
|
import { deliverLedger, drainForRestart, RestartLedger } from "./drain.js";
|
|
25
27
|
import { bundledInfo } from "./extensions/index.js";
|
|
@@ -135,7 +137,12 @@ new CredentialStore(db, secrets), piConfig,
|
|
|
135
137
|
() => settings.get().modelMenu,
|
|
136
138
|
// Bundled extensions the Console switched on; read per session open, so the
|
|
137
139
|
// toggle reaches the next session the same way an edited agent file does.
|
|
138
|
-
() => settings.get().extensions
|
|
140
|
+
() => settings.get().extensions,
|
|
141
|
+
// The title model, if the operator picked one; read when a first turn ends.
|
|
142
|
+
() => settings.get().titleModel,
|
|
143
|
+
// The search index reads Pi's transcripts, which carry the speaker header
|
|
144
|
+
// core wrote for the model; this is where the two areas meet.
|
|
145
|
+
new IndexedListing(undefined, undefined, (text) => splitSpeaker(text).text));
|
|
139
146
|
const hub = new EventHub();
|
|
140
147
|
const router = new Router(hub, (key) => {
|
|
141
148
|
// Web conversation ids ARE session ids; an IM conversation id is a chat or a
|
package/dist/secrets.js
CHANGED
|
@@ -172,7 +172,7 @@ function open(key, sealed, aad) {
|
|
|
172
172
|
}
|
|
173
173
|
/** The real vt CLI. Absent binary or denied approval both surface as the
|
|
174
174
|
* spawn/exit error — unlock() records it and the operator reads it. */
|
|
175
|
-
|
|
175
|
+
const vtCli = {
|
|
176
176
|
read: (record) => run("vt", ["read", record]),
|
|
177
177
|
create: async (plaintext) => {
|
|
178
178
|
const out = await run("vt", ["create"], plaintext);
|
package/dist/service.js
CHANGED
|
@@ -15,7 +15,7 @@ import { existsSync, mkdirSync, readFileSync, rmdirSync, rmSync, writeFileSync }
|
|
|
15
15
|
import { homedir, userInfo } from "node:os";
|
|
16
16
|
import { dirname, join } from "node:path";
|
|
17
17
|
export const UNIT_NAME = "pier.service";
|
|
18
|
-
|
|
18
|
+
const UPDATE_UNIT_NAME = "pier-update.service";
|
|
19
19
|
/** `~/.config/systemd/user/pier.service` — where a user unit belongs. */
|
|
20
20
|
export const unitPath = (home = homedir()) => join(home, ".config", "systemd", "user", UNIT_NAME);
|
|
21
21
|
/** The drop-in Pier writes once and never touches again: what the unit may
|
|
@@ -146,7 +146,7 @@ ExecStopPost=systemctl --user start ${UNIT_NAME}
|
|
|
146
146
|
* exists to protect the OS and sshd outside it. Written commented so the
|
|
147
147
|
* operator tuning it can see what each line buys.
|
|
148
148
|
*/
|
|
149
|
-
|
|
149
|
+
function renderLimits() {
|
|
150
150
|
return `[Service]
|
|
151
151
|
# Soft ceiling: past this the kernel reclaims hard and lets the unit crawl
|
|
152
152
|
# instead of killing anything. This is the one that should bite first.
|
package/dist/settings.js
CHANGED
|
@@ -50,27 +50,40 @@ export function normalizeModelMenu(raw) {
|
|
|
50
50
|
return null;
|
|
51
51
|
const menu = [];
|
|
52
52
|
for (const item of raw) {
|
|
53
|
-
|
|
53
|
+
const ref = normalizeModelRef(item);
|
|
54
|
+
if (!ref)
|
|
54
55
|
return null;
|
|
55
|
-
const {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (
|
|
56
|
+
const { thinking, note } = item;
|
|
57
|
+
// The one thing repaired rather than rejected, because it is not input:
|
|
58
|
+
// entries stored (or exported by an instance) before the level was
|
|
59
|
+
// required have none, and dropping the menu over it would lose the pins.
|
|
60
|
+
const level = thinking === undefined ? "medium" : thinking;
|
|
61
|
+
if (!isThinkingLevel(level))
|
|
61
62
|
return null;
|
|
62
63
|
if (note !== undefined && typeof note !== "string")
|
|
63
64
|
return null;
|
|
64
65
|
const cleaned = note?.trim().slice(0, 200);
|
|
65
66
|
menu.push({
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
...(thinking !== undefined ? { thinking } : {}),
|
|
67
|
+
...ref,
|
|
68
|
+
thinking: level,
|
|
69
69
|
...(cleaned ? { note: cleaned } : {}),
|
|
70
70
|
});
|
|
71
71
|
}
|
|
72
72
|
return menu;
|
|
73
73
|
}
|
|
74
|
+
/** One model reference, rejecting rather than repairing — the menu above is
|
|
75
|
+
* built from these. Existence is not checked here: the catalog is the
|
|
76
|
+
* agent's, and a model that went away is reported by the call that fails. */
|
|
77
|
+
export function normalizeModelRef(raw) {
|
|
78
|
+
if (typeof raw !== "object" || raw === null)
|
|
79
|
+
return null;
|
|
80
|
+
const { provider, id } = raw;
|
|
81
|
+
if (typeof provider !== "string" || !provider.trim())
|
|
82
|
+
return null;
|
|
83
|
+
if (typeof id !== "string" || !id.trim())
|
|
84
|
+
return null;
|
|
85
|
+
return { provider: provider.trim(), id: id.trim() };
|
|
86
|
+
}
|
|
74
87
|
/**
|
|
75
88
|
* Shape only — an unknown name is not an error here. This file must not know
|
|
76
89
|
* what Pier bundles (that catalog is code, and importing it would drag the Pi
|
|
@@ -107,9 +120,11 @@ export class SettingsStore {
|
|
|
107
120
|
this.#db = db;
|
|
108
121
|
}
|
|
109
122
|
get() {
|
|
123
|
+
const titleModel = this.#json("titleModel", normalizeModelRef, "a {provider, id}");
|
|
110
124
|
return {
|
|
111
125
|
publicUrl: this.#value("publicUrl") ?? "",
|
|
112
126
|
modelMenu: this.#json("modelMenu", normalizeModelMenu, "a valid menu") ?? [],
|
|
127
|
+
...(titleModel ? { titleModel } : {}),
|
|
113
128
|
autoUpdate: this.#value("autoUpdate") === "1",
|
|
114
129
|
extensions: this.#json("extensions", normalizeExtensions, "a list of names") ?? [],
|
|
115
130
|
tools: this.#json("tools", normalizeTools, "a list of names") ?? [],
|
|
@@ -152,6 +167,12 @@ export class SettingsStore {
|
|
|
152
167
|
this.#set("modelMenu", JSON.stringify(menu));
|
|
153
168
|
return this.get();
|
|
154
169
|
}
|
|
170
|
+
/** Same contract: hand this `normalizeModelRef`'s output; null switches
|
|
171
|
+
* auto-titling off. */
|
|
172
|
+
setTitleModel(ref) {
|
|
173
|
+
this.#set("titleModel", ref ? JSON.stringify(ref) : "");
|
|
174
|
+
return this.get();
|
|
175
|
+
}
|
|
155
176
|
setAutoUpdate(on) {
|
|
156
177
|
this.#set("autoUpdate", on ? "1" : "0");
|
|
157
178
|
return this.get();
|
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")
|