@timqi/pier 0.1.14 → 0.3.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/README.md +44 -22
- package/dist/agent/config.js +2 -2
- package/dist/agent/events.js +32 -3
- package/dist/agent/listing.js +14 -10
- package/dist/agent/packages.js +6 -10
- package/dist/agent/pi.js +113 -70
- package/dist/agent/roles.js +104 -0
- package/dist/agent/types.js +78 -0
- package/dist/boards/boards.js +42 -32
- package/dist/channels/chains.js +0 -4
- package/dist/channels/commands.js +7 -12
- package/dist/channels/config.js +27 -1
- package/dist/channels/control.js +5 -16
- package/dist/channels/conversations.js +4 -9
- package/dist/channels/lark-api.js +20 -26
- package/dist/channels/lark-outbound.js +21 -29
- package/dist/channels/lark-panel.js +1 -1
- package/dist/channels/lark.js +81 -39
- package/dist/channels/lines.js +33 -1
- package/dist/channels/panel.js +17 -107
- package/dist/channels/routes.js +11 -21
- package/dist/channels/runtime.js +79 -25
- package/dist/channels/slack-outbound.js +12 -14
- package/dist/channels/slack-panel.js +1 -1
- package/dist/channels/slack-render.js +1 -1
- package/dist/channels/slack-thread.js +65 -0
- package/dist/channels/slack.js +85 -124
- package/dist/channels/types.js +1 -1
- package/dist/cli.js +7 -2
- package/dist/config-sync.js +42 -37
- package/dist/core/chain.js +221 -0
- package/dist/core/identity.js +36 -13
- package/dist/core/inbound-file.js +11 -4
- package/dist/core/reply.js +111 -81
- package/dist/core/router.js +111 -118
- package/dist/core/search.js +65 -0
- package/dist/core/types.js +38 -77
- package/dist/db.js +41 -2
- package/dist/drain.js +1 -1
- package/dist/main.js +65 -40
- package/dist/settings.js +26 -19
- package/dist/socket.js +1 -0
- package/dist/tasks/agent.js +11 -5
- package/dist/tasks/callbacks.js +55 -5
- package/dist/tasks/cli.js +22 -11
- package/dist/tasks/definitions.js +30 -17
- package/dist/tasks/execution.js +11 -5
- package/dist/tasks/groups.js +5 -2
- package/dist/tasks/messages.js +7 -7
- package/dist/tasks/open-items.js +88 -0
- package/dist/tasks/operations.js +76 -39
- package/dist/tasks/outbox.js +19 -6
- package/dist/tasks/routes.js +19 -249
- package/dist/tasks/service.js +166 -34
- package/dist/tasks/store.js +123 -57
- package/dist/tasks/types.js +3 -0
- package/dist/tools.js +12 -5
- package/dist/web/auth.js +2 -2
- package/dist/web/config-sync.js +4 -5
- package/dist/web/config.js +9 -2
- package/dist/web/explorer.js +2 -2
- package/dist/web/fs.js +16 -1
- package/dist/web/instance.js +8 -5
- package/dist/web/packages.js +1 -1
- package/dist/web/passkeys.js +3 -3
- package/dist/web/providers.js +3 -3
- package/dist/web/public/assets/code-CWT27KH1.js +2 -0
- package/dist/web/public/assets/code-CWT27KH1.js.br +0 -0
- package/dist/web/public/assets/code-CWT27KH1.js.gz +0 -0
- package/dist/web/public/assets/explorer-C4DhXFCm.js +5 -0
- package/dist/web/public/assets/explorer-C4DhXFCm.js.br +0 -0
- package/dist/web/public/assets/explorer-C4DhXFCm.js.gz +0 -0
- package/dist/web/public/assets/highlight-CDSk2hRJ.js +72 -0
- package/dist/web/public/assets/highlight-CDSk2hRJ.js.br +0 -0
- package/dist/web/public/assets/highlight-CDSk2hRJ.js.gz +0 -0
- package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
- package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
- package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
- package/dist/web/public/assets/index-D0ZYdweB.js +14 -0
- package/dist/web/public/assets/index-D0ZYdweB.js.br +0 -0
- package/dist/web/public/assets/index-D0ZYdweB.js.gz +0 -0
- package/dist/web/public/assets/index-JN0Ic8K5.css +2 -0
- package/dist/web/public/assets/index-JN0Ic8K5.css.br +0 -0
- package/dist/web/public/assets/index-JN0Ic8K5.css.gz +0 -0
- package/dist/web/public/assets/settings-DSTeuIyc.js +9 -0
- package/dist/web/public/assets/settings-DSTeuIyc.js.br +0 -0
- package/dist/web/public/assets/settings-DSTeuIyc.js.gz +0 -0
- package/dist/web/public/index.html +39 -109
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest +0 -2
- package/dist/web/push.js +3 -3
- package/dist/web/server.js +89 -116
- package/dist/web/session-state.js +4 -40
- package/docs/deploy.md +5 -3
- package/package.json +2 -1
- package/skills/pier-boards/SKILL.md +14 -4
- package/skills/pier-help/SKILL.md +62 -23
- package/skills/pier-search/SKILL.md +27 -0
- package/skills/pier-tasks/SKILL.md +68 -18
- package/dist/channels/handoff.js +0 -94
- package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
- package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
- package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
- package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
- package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
- package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
- package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
- package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
- package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
- package/dist/web/public/assets/index-BQo-haPN.js +0 -85
- package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
- package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
- package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
- package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
- package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
- package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
- package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
- package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
- package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
- package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
- package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
- package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
- package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
- package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
- package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
- package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
- package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// What a task *is* before it ever runs: the id it is minted with, the draft
|
|
2
2
|
// validated into a definition, and when its trigger is next due. Every way a
|
|
3
|
-
// definition can be created —
|
|
3
|
+
// definition can be created — `pier task`, Pier's own owned task —
|
|
4
4
|
// arrives here, so a field is checked in one place or nowhere.
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
6
|
import { stat } from "node:fs/promises";
|
|
@@ -69,13 +69,27 @@ function parseTrigger(raw) {
|
|
|
69
69
|
}
|
|
70
70
|
/** Always computed from `from` (boot recomputes from *now*): cron runs missed
|
|
71
71
|
* while Pier was down are skipped, never caught up — no double fire. */
|
|
72
|
-
|
|
72
|
+
function nextRunAt(trigger, from) {
|
|
73
73
|
if (trigger.type === "manual")
|
|
74
74
|
return null;
|
|
75
75
|
if (trigger.type === "watch")
|
|
76
76
|
return from + trigger.intervalSeconds * 1000;
|
|
77
77
|
return new Cron(trigger.expression, { timezone: trigger.timezone }).nextRun(new Date(from))?.getTime() ?? null;
|
|
78
78
|
}
|
|
79
|
+
function parseModel(raw) {
|
|
80
|
+
const model = record(raw);
|
|
81
|
+
if (!model)
|
|
82
|
+
throw new Error("agent model must be an object");
|
|
83
|
+
return {
|
|
84
|
+
provider: requiredString(model.provider, "model provider"),
|
|
85
|
+
id: requiredString(model.id, "model id"),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function parseThinking(raw) {
|
|
89
|
+
if (!isThinkingLevel(raw))
|
|
90
|
+
throw new Error("invalid agent thinking level");
|
|
91
|
+
return raw;
|
|
92
|
+
}
|
|
79
93
|
function parseLaunch(raw) {
|
|
80
94
|
if (raw === undefined)
|
|
81
95
|
return undefined;
|
|
@@ -83,20 +97,19 @@ function parseLaunch(raw) {
|
|
|
83
97
|
if (!value)
|
|
84
98
|
throw new Error("agent launch policy must be an object");
|
|
85
99
|
const launch = {};
|
|
86
|
-
if (value.model !== undefined)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
100
|
+
if (value.model !== undefined)
|
|
101
|
+
launch.model = parseModel(value.model);
|
|
102
|
+
if (value.thinking !== undefined)
|
|
103
|
+
launch.thinking = parseThinking(value.thinking);
|
|
104
|
+
if (value.role !== undefined) {
|
|
105
|
+
if (value.role !== "lead")
|
|
106
|
+
throw new Error("agent role must be lead");
|
|
107
|
+
launch.role = "lead";
|
|
94
108
|
}
|
|
95
|
-
if (value.
|
|
96
|
-
if (
|
|
97
|
-
throw new Error("
|
|
98
|
-
|
|
99
|
-
launch.thinking = value.thinking;
|
|
109
|
+
if (value.design !== undefined) {
|
|
110
|
+
if (value.design !== true || launch.role !== "lead")
|
|
111
|
+
throw new Error("agent design must be true, on a lead");
|
|
112
|
+
launch.design = true;
|
|
100
113
|
}
|
|
101
114
|
return Object.keys(launch).length ? launch : undefined;
|
|
102
115
|
}
|
|
@@ -136,7 +149,7 @@ export class TaskDefinitions {
|
|
|
136
149
|
revision: 1,
|
|
137
150
|
trigger: draft.trigger,
|
|
138
151
|
action: draft.action,
|
|
139
|
-
callback: draft.callback ?? { type: "
|
|
152
|
+
callback: draft.callback ?? { type: "conversation" },
|
|
140
153
|
timeoutSeconds: draft.timeoutSeconds ?? DEFAULT_TIMEOUT,
|
|
141
154
|
nextRunAt: null,
|
|
142
155
|
creator,
|
|
@@ -321,7 +334,7 @@ export class TaskDefinitions {
|
|
|
321
334
|
if (raw === undefined)
|
|
322
335
|
return undefined;
|
|
323
336
|
const value = record(raw);
|
|
324
|
-
if (!value || (value.type !== "none" && value.type !== "origin" && value.type !== "session")) {
|
|
337
|
+
if (!value || (value.type !== "conversation" && value.type !== "none" && value.type !== "origin" && value.type !== "session")) {
|
|
325
338
|
throw new Error("invalid callback");
|
|
326
339
|
}
|
|
327
340
|
if (value.type !== "session")
|
package/dist/tasks/execution.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// One queued run carried to a result: dispatched by action kind, abortable,
|
|
2
2
|
// settled exactly once. The lifecycle command.ts and agent.ts share.
|
|
3
3
|
import { logger } from "../log.js";
|
|
4
|
+
import { settleCallback } from "./callbacks.js";
|
|
4
5
|
import { runBash } from "./command.js";
|
|
6
|
+
import { INTERRUPTED } from "./store.js";
|
|
5
7
|
const log = logger("tasks");
|
|
8
|
+
const SHUTDOWN = Symbol("shutdown");
|
|
6
9
|
export class TaskExecution {
|
|
7
10
|
store;
|
|
8
11
|
definitions;
|
|
@@ -20,9 +23,11 @@ export class TaskExecution {
|
|
|
20
23
|
start(run) {
|
|
21
24
|
void this.execute(run);
|
|
22
25
|
}
|
|
26
|
+
/** Shutdown: the abort still kills each detached bash group, which boot
|
|
27
|
+
* recovery could not, but the run ends as that recovery would record it. */
|
|
23
28
|
stop() {
|
|
24
29
|
for (const controller of this.controllers.values())
|
|
25
|
-
controller.abort();
|
|
30
|
+
controller.abort(SHUTDOWN);
|
|
26
31
|
}
|
|
27
32
|
cancel(id) {
|
|
28
33
|
log.info(`run ${id} cancel requested`);
|
|
@@ -74,9 +79,11 @@ export class TaskExecution {
|
|
|
74
79
|
}
|
|
75
80
|
catch (error) {
|
|
76
81
|
// A killed child reports `exited null`; report why we aborted instead.
|
|
82
|
+
// The first abort's reason wins: a shutdown after a user cancel stays cancelled.
|
|
77
83
|
const aborted = controller.signal.aborted;
|
|
78
|
-
|
|
79
|
-
run.
|
|
84
|
+
const interrupted = aborted && !timedOut && controller.signal.reason === SHUTDOWN;
|
|
85
|
+
run.state = aborted ? (timedOut ? "failed" : interrupted ? "interrupted" : "cancelled") : "failed";
|
|
86
|
+
run.error = timedOut ? "task timed out" : interrupted ? INTERRUPTED : aborted ? "cancelled" : String(error);
|
|
80
87
|
cause = timedOut ? run.error : error;
|
|
81
88
|
}
|
|
82
89
|
finally {
|
|
@@ -92,8 +99,7 @@ export class TaskExecution {
|
|
|
92
99
|
log.debug(`${settled} (watch did not match)`);
|
|
93
100
|
else
|
|
94
101
|
log.info(settled);
|
|
95
|
-
|
|
96
|
-
run.callbackState = "pending";
|
|
102
|
+
settleCallback(run, this.store);
|
|
97
103
|
this.controllers.delete(run.id);
|
|
98
104
|
try {
|
|
99
105
|
this.store.saveRun(run);
|
package/dist/tasks/groups.js
CHANGED
|
@@ -13,12 +13,14 @@ export class TaskGroups {
|
|
|
13
13
|
host;
|
|
14
14
|
changed;
|
|
15
15
|
unreachable;
|
|
16
|
+
headOf;
|
|
16
17
|
outbox;
|
|
17
|
-
constructor(store, router, host, changed, unreachable) {
|
|
18
|
+
constructor(store, router, host, changed, unreachable, headOf = (id) => id, milestone) {
|
|
18
19
|
this.store = store;
|
|
19
20
|
this.host = host;
|
|
20
21
|
this.changed = changed;
|
|
21
22
|
this.unreachable = unreachable;
|
|
23
|
+
this.headOf = headOf;
|
|
22
24
|
this.outbox = new Outbox(router, {
|
|
23
25
|
id: (group) => group.id,
|
|
24
26
|
reload: (id) => this.store.getGroup(id),
|
|
@@ -31,6 +33,7 @@ export class TaskGroups {
|
|
|
31
33
|
origin: { kind: "task-callback", taskId: groups[0].id, runId: groups[0].id, sourceSessionId: null },
|
|
32
34
|
}),
|
|
33
35
|
abandoned: (group, sessionId, why) => unreachable(sessionId, `the result of a ${String(group.memberRunIds.length)}-run group`, why),
|
|
36
|
+
milestone,
|
|
34
37
|
});
|
|
35
38
|
}
|
|
36
39
|
/** Enqueues every member or none: a partially started group is worse than
|
|
@@ -141,7 +144,7 @@ export class TaskGroups {
|
|
|
141
144
|
const group = this.store.getGroup(candidate.id);
|
|
142
145
|
if (!group?.callbackSessionId || (group.callbackState !== "pending" && group.callbackState !== "failed"))
|
|
143
146
|
return;
|
|
144
|
-
await this.outbox.deliver(group.callbackSessionId, [group]);
|
|
147
|
+
await this.outbox.deliver(this.headOf(group.callbackSessionId), [group]);
|
|
145
148
|
}
|
|
146
149
|
text(group) {
|
|
147
150
|
const members = group.memberRunIds.map((id) => this.host.getRun(id));
|
package/dist/tasks/messages.js
CHANGED
|
@@ -23,13 +23,17 @@ export class TaskMessenger {
|
|
|
23
23
|
store;
|
|
24
24
|
hub;
|
|
25
25
|
unreachable;
|
|
26
|
+
runChanged;
|
|
26
27
|
outbox;
|
|
27
28
|
constructor(store, router, hub,
|
|
28
29
|
/** Reports a delivery nobody can complete (service.ts owns the surfaces). */
|
|
29
|
-
unreachable
|
|
30
|
+
unreachable,
|
|
31
|
+
/** A follow-up's run card counts it while it is pending (service.ts). */
|
|
32
|
+
runChanged) {
|
|
30
33
|
this.store = store;
|
|
31
34
|
this.hub = hub;
|
|
32
35
|
this.unreachable = unreachable;
|
|
36
|
+
this.runChanged = runChanged;
|
|
33
37
|
this.outbox = new Outbox(router, {
|
|
34
38
|
id: ({ message }) => message.id,
|
|
35
39
|
reload: (id) => {
|
|
@@ -68,12 +72,6 @@ export class TaskMessenger {
|
|
|
68
72
|
for (const message of this.store.expirePendingMessages())
|
|
69
73
|
this.changed(message);
|
|
70
74
|
}
|
|
71
|
-
list(runId) {
|
|
72
|
-
return this.store.listMessages(runId);
|
|
73
|
-
}
|
|
74
|
-
recent(since) {
|
|
75
|
-
return this.store.listRecentMessages(since);
|
|
76
|
-
}
|
|
77
75
|
async control(run, fromSessionId, kind, content) {
|
|
78
76
|
const message = this.create(run, kind, fromSessionId, run.targetSessionId ?? "", content);
|
|
79
77
|
this.changed(message);
|
|
@@ -178,5 +176,7 @@ export class TaskMessenger {
|
|
|
178
176
|
/** Publish only after any transaction changing this message has committed. */
|
|
179
177
|
changed(message) {
|
|
180
178
|
this.hub.emitWorkspace({ type: "task-message-changed", runId: message.runId, messageId: message.id });
|
|
179
|
+
if (message.kind === "follow_up")
|
|
180
|
+
this.runChanged(message.runId);
|
|
181
181
|
}
|
|
182
182
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The continuous conversation's open items in task-run words: main's markers
|
|
2
|
+
// joined to the run ledger by session, a lead's workers counted by state, and
|
|
3
|
+
// the one text every surface shows (docs/design/10-continuous-session.md#open-items).
|
|
4
|
+
import { openItemMarkers, openRunText } from "../core/reply.js";
|
|
5
|
+
import { LEDGER_WINDOW_MS, NOT_IN_LEDGER, TASK_RUN_STATES } from "../core/types.js";
|
|
6
|
+
import { logger } from "../log.js";
|
|
7
|
+
const log = logger("tasks");
|
|
8
|
+
const IN_FLIGHT = new Set(["queued", "running"]);
|
|
9
|
+
/** `members`: the chain, newest first; `designs`: the open designs of sessions not closed. */
|
|
10
|
+
export function openItems(tasks, router, members, designs, now) {
|
|
11
|
+
const since = now - LEDGER_WINDOW_MS;
|
|
12
|
+
const runs = members.length ? tasks.ledger(members, since) : [];
|
|
13
|
+
const awaiting = new Set(designs.flatMap((d) => (d.targetSessionId ? [d.targetSessionId] : [])));
|
|
14
|
+
const statusOf = (named) => named.some((r) => IN_FLIGHT.has(r.state) || (r.targetSessionId && router.stateOf(r.targetSessionId) === "streaming"))
|
|
15
|
+
? "running"
|
|
16
|
+
: named.some((r) => r.targetSessionId && awaiting.has(r.targetSessionId))
|
|
17
|
+
? "waiting on you"
|
|
18
|
+
: named.length && named.every((r) => r.state === "succeeded") ? "pending release" : "waiting on you";
|
|
19
|
+
// An item names a session through any of its runs: the session's newest run stands for it.
|
|
20
|
+
const tracked = new Set();
|
|
21
|
+
const items = tasks.store.openItems().map((row) => {
|
|
22
|
+
const named = row.runIds.map((id) => {
|
|
23
|
+
const session = runs.find((r) => r.runId === id)?.targetSessionId ?? tasks.store.getRun(id)?.targetSessionId ?? null;
|
|
24
|
+
tracked.add(session ?? id);
|
|
25
|
+
const run = runs.find((r) => (session ? r.targetSessionId === session : r.runId === id));
|
|
26
|
+
return run ?? { runId: id, name: id, state: NOT_IN_LEDGER, targetSessionId: session, cwd: null, queuedAt: 0, finishedAt: null };
|
|
27
|
+
}).filter((r, i, all) => all.findIndex((o) => o.runId === r.runId) === i);
|
|
28
|
+
return { problem: row.problem, stage: row.stage, runs: named, status: statusOf(named) };
|
|
29
|
+
});
|
|
30
|
+
const unlisted = runs.filter((r) => IN_FLIGHT.has(r.state) && !tracked.has(r.targetSessionId ?? r.runId));
|
|
31
|
+
for (const r of unlisted)
|
|
32
|
+
tracked.add(r.targetSessionId ?? r.runId);
|
|
33
|
+
const unheld = designs.filter((d) => !tracked.has(d.targetSessionId ?? d.runId)).map((d) => {
|
|
34
|
+
const named = [runs.find((r) => d.targetSessionId && r.targetSessionId === d.targetSessionId) ?? d];
|
|
35
|
+
return { problem: d.name, stage: "", runs: named, status: statusOf(named) };
|
|
36
|
+
});
|
|
37
|
+
const all = [...items, ...unheld];
|
|
38
|
+
// Every shown lead's workers in one read: a ledger call per lead grows with the list.
|
|
39
|
+
const leads = tasks.store.leads();
|
|
40
|
+
const leadOf = (r) => (r.state !== NOT_IN_LEDGER && r.targetSessionId && leads.has(r.targetSessionId) ? r.targetSessionId : null);
|
|
41
|
+
const shown = new Set([...all.flatMap((i) => i.runs), ...unlisted].flatMap((r) => leadOf(r) ?? []));
|
|
42
|
+
const counts = new Map([...shown].map((id) => [id, Object.fromEntries(TASK_RUN_STATES.map((s) => [s, 0]))]));
|
|
43
|
+
for (const w of shown.size ? tasks.store.ledgerRuns([...shown], since) : []) {
|
|
44
|
+
const c = w.invokedBySessionId ? counts.get(w.invokedBySessionId) : undefined;
|
|
45
|
+
if (c)
|
|
46
|
+
c[w.state] += 1;
|
|
47
|
+
}
|
|
48
|
+
const withWorkers = (r) => {
|
|
49
|
+
const workers = counts.get(leadOf(r) ?? "");
|
|
50
|
+
return workers ? { ...r, workers: { ...workers } } : r;
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
items: all.map((i) => ({ ...i, runs: i.runs.map(withWorkers) })),
|
|
54
|
+
unlisted: unlisted.map(withWorkers),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** The head's reply's markers, written; answers whether a row changed. */
|
|
58
|
+
export function recordOpenItems(store, text, now) {
|
|
59
|
+
const { markers, dropped } = openItemMarkers(text);
|
|
60
|
+
for (const marker of dropped)
|
|
61
|
+
log.warn(`open items: dropped a marker with no problem text: ${marker}`);
|
|
62
|
+
return markers.length > 0 && store.markOpenItems(markers, now) > 0;
|
|
63
|
+
}
|
|
64
|
+
/** An `unlisted` run's stage: main named it in no item. */
|
|
65
|
+
const UNLISTED = "not on the list";
|
|
66
|
+
/** `status` is an item's, or `queued` for an unlisted run not started, as the panel tags it. */
|
|
67
|
+
const itemLine = (i, now) => `- ${i.problem}${i.stage ? ` — ${i.stage}` : ""} (${i.status})${i.runs.map((r) => ` · ${openRunText(r, now)}`).join("")}`;
|
|
68
|
+
/** The one string every surface shows for the open items: `/status` and the seed.
|
|
69
|
+
* Grouped as the status panel groups them (web/ui/drawer.ts): what waits on the user first. */
|
|
70
|
+
function renderOpenItems({ items, unlisted }, now) {
|
|
71
|
+
if (!items.length && !unlisted.length)
|
|
72
|
+
return "Nothing open.";
|
|
73
|
+
const waiting = items.filter((i) => i.status !== "running").map((i) => itemLine(i, now));
|
|
74
|
+
const running = [
|
|
75
|
+
...items.filter((i) => i.status === "running").map((i) => itemLine(i, now)),
|
|
76
|
+
...unlisted.map((r) => itemLine({ problem: r.name, stage: UNLISTED, runs: [r], status: r.state === "queued" ? "queued" : "running" }, now)),
|
|
77
|
+
];
|
|
78
|
+
return [
|
|
79
|
+
...(waiting.length ? ["Waiting on you", ...waiting] : []),
|
|
80
|
+
...(running.length ? ["In progress", ...running] : []),
|
|
81
|
+
].join("\n");
|
|
82
|
+
}
|
|
83
|
+
/** `/status`'s card: the text, and run id → session id for every run it names that has one. */
|
|
84
|
+
export function openItemsStatus(open, now) {
|
|
85
|
+
const sessions = Object.fromEntries([...open.items.flatMap((i) => i.runs), ...open.unlisted]
|
|
86
|
+
.flatMap((r) => (r.targetSessionId ? [[r.runId, r.targetSessionId]] : [])));
|
|
87
|
+
return { text: renderOpenItems(open, now), sessions };
|
|
88
|
+
}
|
package/dist/tasks/operations.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// The `/task` socket route: every `pier task` operation — run, message, save,
|
|
2
|
-
// list, cancel, recover — validated here once, answered
|
|
3
|
-
// model reads. Scheduling and delivery stay in the
|
|
4
|
-
// who may ask for what.
|
|
2
|
+
// list, pause, resume, archive, cancel, recover — validated here once, answered
|
|
3
|
+
// with the summaries a model reads. Scheduling and delivery stay in the
|
|
4
|
+
// service; this file decides who may ask for what.
|
|
5
5
|
import { isAbsolute, resolve } from "node:path";
|
|
6
|
+
import { isModelTier, LEDGER_WINDOW_MS } from "../core/types.js";
|
|
6
7
|
import { logger } from "../log.js";
|
|
7
8
|
import { record, requiredString } from "./definitions.js";
|
|
8
9
|
import { isTerminal } from "./types.js";
|
|
@@ -104,35 +105,67 @@ function cancelledOrCurrent(host, id, ms) {
|
|
|
104
105
|
new Promise((resolve) => setTimeout(() => resolve(host.getRun(id)), ms).unref()),
|
|
105
106
|
]);
|
|
106
107
|
}
|
|
107
|
-
export async function handleTask(host, definitions, store, raw, callerSessionId) {
|
|
108
|
+
export async function handleTask(host, definitions, store, raw, callerSessionId, chain) {
|
|
109
|
+
// The launching session controls a run, and so does the run's own; every
|
|
110
|
+
// member of the continuous conversation counts as the one that launched it.
|
|
111
|
+
const launchers = () => chain.chainOf(callerSessionId) ?? [callerSessionId];
|
|
112
|
+
const assertOwns = (target) => {
|
|
113
|
+
if (!launchers().includes(target.invokedBySessionId ?? "") && target.targetSessionId !== callerSessionId) {
|
|
114
|
+
throw new Error("session does not own this run");
|
|
115
|
+
}
|
|
116
|
+
};
|
|
108
117
|
const input = record(raw);
|
|
109
118
|
if (!input)
|
|
110
119
|
throw new Error("task parameters required");
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
120
|
+
// Depth stays 2 (docs/design/09-tasks-cli.md §Two levels, no tree); a queued
|
|
121
|
+
// run has not taken the session's turn, so it gates nothing yet.
|
|
122
|
+
const role = store.roleOf(callerSessionId);
|
|
123
|
+
const lead = role === "lead";
|
|
114
124
|
const active = store.findActiveRunForTarget(callerSessionId);
|
|
115
|
-
if (active?.state === "running" && store.supervised(active))
|
|
125
|
+
if (active?.state === "running" && store.supervised(active) && !lead)
|
|
116
126
|
throw new Error("a delegated run cannot delegate; ask in your result and let your supervisor run it");
|
|
127
|
+
if (role === "worker")
|
|
128
|
+
throw new Error("a worker's session never delegates, in a run or after it; say what needs another agent and let the session that launched it run it");
|
|
129
|
+
// Read off a draft before it is filed; a saved definition has the same shape under `action`.
|
|
130
|
+
const notLead = (draft) => {
|
|
131
|
+
const role = record(record(draft)?.launch)?.role ?? record(record(record(draft)?.action)?.launch)?.role;
|
|
132
|
+
if (lead && role === "lead")
|
|
133
|
+
throw new Error("a feature lead cannot launch a lead; a build lead is your supervisor's to launch, from your milestone");
|
|
134
|
+
return draft;
|
|
135
|
+
};
|
|
117
136
|
const menu = () => host.models().then((listed) => listed.models);
|
|
118
|
-
if (input.operation === "list")
|
|
119
|
-
|
|
137
|
+
if (input.operation === "list") {
|
|
138
|
+
// What the schedule answer needs: the next occurrence and the last run's state, not the run.
|
|
139
|
+
return definitions.list().filter((task) => task.kind !== "subagent").map(({ nextRunAt, ...task }) => {
|
|
140
|
+
const last = host.listRuns(task.id, 1)[0];
|
|
141
|
+
return { ...task, nextRun: nextRunAt, lastRun: last ? defined({ runId: last.id, state: last.state, startedAt: last.startedAt, finishedAt: last.finishedAt }) : null };
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (input.operation === "runs")
|
|
145
|
+
return host.ledger(launchers(), Date.now() - LEDGER_WINDOW_MS);
|
|
146
|
+
// A one-shot's hidden definition is a run's record, not a task anyone filed.
|
|
147
|
+
const filed = () => {
|
|
148
|
+
const id = requiredString(input.task_id, "task_id");
|
|
149
|
+
if (definitions.get(id).kind === "subagent")
|
|
150
|
+
throw new Error(`${id} is a one-shot run's own definition; save without --task-id to file a task`);
|
|
151
|
+
return id;
|
|
152
|
+
};
|
|
120
153
|
if (input.operation === "save") {
|
|
121
154
|
const draft = await expandDraft(definitions, menu, input.task, callerSessionId);
|
|
122
155
|
if (input.task_id === undefined)
|
|
123
156
|
return definitions.create(draft, `session:${callerSessionId}`);
|
|
124
|
-
|
|
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);
|
|
157
|
+
return definitions.update(filed(), draft);
|
|
129
158
|
}
|
|
159
|
+
if (input.operation === "pause" || input.operation === "resume")
|
|
160
|
+
return host.setEnabled(filed(), input.operation === "resume");
|
|
161
|
+
if (input.operation === "archive")
|
|
162
|
+
return host.archive(filed());
|
|
130
163
|
if (input.operation === "run") {
|
|
131
164
|
// `--model ?`: the menu instead of a run, the one lookup the common case never pays.
|
|
132
165
|
if (record(input.launch)?.model === "?") {
|
|
133
166
|
const { source, models } = await host.models();
|
|
134
167
|
const head = source === "menu" ? "the operator's menu" : "the live catalog (no model pinned)";
|
|
135
|
-
return `${head} — --model takes a provider/id or a unique substring of one:\n${menuLines(models)}`;
|
|
168
|
+
return `${head} — --model takes a tier, a provider/id or a unique substring of one:\n${menuLines(models)}`;
|
|
136
169
|
}
|
|
137
170
|
const callbackMode = callbackModeOf(input);
|
|
138
171
|
if (Array.isArray(input.tasks)) {
|
|
@@ -149,8 +182,8 @@ export async function handleTask(host, definitions, store, raw, callerSessionId)
|
|
|
149
182
|
if (!entry)
|
|
150
183
|
throw new Error("invalid tasks[] entry");
|
|
151
184
|
resolved.push(entry.task_id === undefined
|
|
152
|
-
? await resolveDraft(definitions, menu, entry, callerSessionId)
|
|
153
|
-
: definitions.get(requiredString(entry.task_id, "task_id")));
|
|
185
|
+
? await resolveDraft(definitions, menu, notLead(entry), callerSessionId)
|
|
186
|
+
: notLead(definitions.get(requiredString(entry.task_id, "task_id"))));
|
|
154
187
|
}
|
|
155
188
|
const groupCallbackSessionId = input.callback === "none" ? null : callerSessionId;
|
|
156
189
|
const { group, runs } = host.runGroup(resolved, oneOf(input, "join", ["all", "first"]) ?? "all", callerSessionId, groupCallbackSessionId, callbackMode);
|
|
@@ -158,8 +191,8 @@ export async function handleTask(host, definitions, store, raw, callerSessionId)
|
|
|
158
191
|
}
|
|
159
192
|
const draft = input.task_id === undefined ? inlineDraft(input) : undefined;
|
|
160
193
|
const task = draft
|
|
161
|
-
? await resolveDraft(definitions, menu, draft, callerSessionId)
|
|
162
|
-
: definitions.get(requiredString(input.task_id, "task_id"));
|
|
194
|
+
? await resolveDraft(definitions, menu, notLead(draft), callerSessionId)
|
|
195
|
+
: notLead(definitions.get(requiredString(input.task_id, "task_id")));
|
|
163
196
|
const callbackSessionId = await callbackTarget(input, definitions, callerSessionId);
|
|
164
197
|
const run = host.run(task.id, null, "agent", null, {
|
|
165
198
|
invokedBySessionId: callerSessionId,
|
|
@@ -205,12 +238,12 @@ export async function handleTask(host, definitions, store, raw, callerSessionId)
|
|
|
205
238
|
if (typeof input.group_id === "string") {
|
|
206
239
|
const { group, members } = host.getGroup(input.group_id);
|
|
207
240
|
for (const member of members)
|
|
208
|
-
assertOwns(
|
|
241
|
+
assertOwns(member);
|
|
209
242
|
const cancelled = host.cancelGroup(group.id);
|
|
210
243
|
return summarizeGroup(cancelled, cancelled.memberRunIds.map((id) => host.getRun(id)));
|
|
211
244
|
}
|
|
212
245
|
const run = host.getRun(requiredString(input.run_id, "run_id"));
|
|
213
|
-
assertOwns(
|
|
246
|
+
assertOwns(run);
|
|
214
247
|
// cancel() only aborts; the row flips ~100 ms later. A receipt still saying
|
|
215
248
|
// `running` reads as "cancel failed", so wait for the flip, briefly.
|
|
216
249
|
host.cancel(run.id);
|
|
@@ -221,7 +254,7 @@ export async function handleTask(host, definitions, store, raw, callerSessionId)
|
|
|
221
254
|
// caller, decides whether the text steers, queues or resumes, so a status
|
|
222
255
|
// query never has to exist.
|
|
223
256
|
const run = host.getRun(requiredString(input.run_id, "run_id"));
|
|
224
|
-
assertOwns(
|
|
257
|
+
assertOwns(run);
|
|
225
258
|
const message = requiredString(input.message, "message");
|
|
226
259
|
if (isTerminal(run.state)) {
|
|
227
260
|
// A resumed run is a new run with its own callback.
|
|
@@ -254,23 +287,33 @@ function nameFrom(text) {
|
|
|
254
287
|
.find(Boolean) ?? "subagent";
|
|
255
288
|
return line.length > 60 ? `${line.slice(0, 59).trimEnd()}…` : line;
|
|
256
289
|
}
|
|
257
|
-
/** `launch.model` by name (docs/design/09-tasks-cli.md §Models)
|
|
258
|
-
*
|
|
259
|
-
* from, so the agent never
|
|
290
|
+
/** `launch.model` by name (docs/design/09-tasks-cli.md §Models): the one pin
|
|
291
|
+
* it names, a tier its first. Anything but a tier, one pin or an unpinned
|
|
292
|
+
* `provider/id` is refused with the lines to pick from, so the agent never
|
|
293
|
+
* guesses an id. A tier is never a substring: "cheap" matching an id would
|
|
294
|
+
* pick a pin the operator did not assign. */
|
|
260
295
|
function resolveModel(name, menu) {
|
|
261
296
|
const needle = name.trim().toLowerCase();
|
|
262
297
|
const full = (pin) => `${pin.provider}/${pin.id}`;
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
298
|
+
const pick = (pin) => ({ model: { provider: pin.provider, id: pin.id }, thinking: pin.thinking });
|
|
299
|
+
if (isModelTier(needle)) {
|
|
300
|
+
const pin = menu.find((p) => p.tier === needle);
|
|
301
|
+
if (pin)
|
|
302
|
+
return pick(pin);
|
|
303
|
+
throw new Error(`model "${name}": tier ${needle} is unassigned — the operator's menu:\n${menuLines(menu)}`);
|
|
304
|
+
}
|
|
305
|
+
const exact = menu.filter((pin) => full(pin).toLowerCase() === needle);
|
|
306
|
+
const hits = exact.length ? exact : menu.filter((pin) => full(pin).toLowerCase().includes(needle));
|
|
307
|
+
// One model pinned at several levels is still one model: its first row, as with a tier.
|
|
308
|
+
if (hits.length && hits.every((pin) => full(pin) === full(hits[0])))
|
|
309
|
+
return pick(hits[0]);
|
|
267
310
|
const slash = name.indexOf("/");
|
|
268
311
|
if (!hits.length && slash > 0 && slash < name.length - 1)
|
|
269
312
|
return { model: { provider: name.slice(0, slash), id: name.slice(slash + 1) } };
|
|
270
313
|
throw new Error(`model "${name}" matches ${String(hits.length)} of the menu:\n${menuLines(hits.length ? hits : menu)}`);
|
|
271
314
|
}
|
|
272
315
|
/** One pin per line, the only shape a menu is ever printed in. */
|
|
273
|
-
const menuLines = (menu) => menu.map((pin) => `${pin.
|
|
316
|
+
const menuLines = (menu) => menu.map((pin) => `${pin.tier ? `${pin.tier} · ` : ""}${pin.provider}/${pin.id}${pin.thinking ? ` · ${pin.thinking}` : ""}`).join("\n")
|
|
274
317
|
|| "(no model is pinned or available)";
|
|
275
318
|
/** A `prompt` shorthand becomes a fresh Agent action in the caller's own
|
|
276
319
|
* directory; everything the caller did spell out passes through to parseDraft. */
|
|
@@ -305,6 +348,7 @@ async function expandDraft(definitions, menu, raw, callerSessionId) {
|
|
|
305
348
|
const launch = record(action?.launch);
|
|
306
349
|
if (typeof launch?.model === "string") {
|
|
307
350
|
const { model, thinking } = resolveModel(launch.model, await menu());
|
|
351
|
+
// A pin's level only where the caller named none.
|
|
308
352
|
const resolved = { ...launch, model, ...(launch.thinking === undefined && thinking ? { thinking } : {}) };
|
|
309
353
|
draft = { ...draft, action: { ...record(draft.action), launch: resolved } };
|
|
310
354
|
}
|
|
@@ -324,12 +368,5 @@ async function resolveDraft(definitions, menu, raw, callerSessionId) {
|
|
|
324
368
|
if (draft.callback !== undefined || draft.callback_session_id !== undefined) {
|
|
325
369
|
throw new Error("an inline task draft cannot set callback; use the top-level callback / callback_session_id");
|
|
326
370
|
}
|
|
327
|
-
return definitions.create({ ...draft, trigger: { type: "manual" } }, `session:${callerSessionId}`, "subagent");
|
|
328
|
-
}
|
|
329
|
-
/** The session that launched a run controls it, and so does the run's own
|
|
330
|
-
* session; a `task` action's child inherits its parent's launcher. */
|
|
331
|
-
function assertOwns(callerSessionId, target) {
|
|
332
|
-
if (target.invokedBySessionId !== callerSessionId && target.targetSessionId !== callerSessionId) {
|
|
333
|
-
throw new Error("session does not own this run");
|
|
334
|
-
}
|
|
371
|
+
return definitions.create({ ...draft, trigger: { type: "manual" }, callback: { type: "none" } }, `session:${callerSessionId}`, "subagent");
|
|
335
372
|
}
|
package/dist/tasks/outbox.js
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
import { logger } from "../log.js";
|
|
4
4
|
import { MAX_DELIVERY_ATTEMPTS, retryDelay, undeliverable } from "./types.js";
|
|
5
5
|
const log = logger("tasks");
|
|
6
|
+
/** A result waiting on a lead's own running turn is asked again this often, not
|
|
7
|
+
* every tick: a wait spends no attempt, so the attempt backoff does not apply. */
|
|
8
|
+
const MILESTONE_WAIT_MS = 10_000;
|
|
9
|
+
/** The marks a delivered record carries: nothing left to retry. */
|
|
10
|
+
const DELIVERED = { callbackState: "delivered", callbackError: null, callbackNextAttemptAt: null };
|
|
6
11
|
/** The records a delivery names, from either side of the seam: a batch names
|
|
7
12
|
* every one of them, a group names itself, a message names its id. */
|
|
8
13
|
const recordIds = (origin) => {
|
|
@@ -24,6 +29,17 @@ export class Outbox {
|
|
|
24
29
|
* written only against the input visible in the recipient's transcript: Pi's
|
|
25
30
|
* queues are memory, so a resolved `systemInput` proves nothing. */
|
|
26
31
|
async deliver(sessionId, batch) {
|
|
32
|
+
const marked = batch.map((record) => ({ ...record, ...DELIVERED }));
|
|
33
|
+
const taken = this.kind.milestone?.(sessionId, () => this.kind.input(batch).text, () => { for (const record of marked)
|
|
34
|
+
this.kind.save(record); });
|
|
35
|
+
if (taken === "resumed")
|
|
36
|
+
for (const record of marked)
|
|
37
|
+
this.kind.changed(record);
|
|
38
|
+
if (taken === "wait")
|
|
39
|
+
for (const record of batch)
|
|
40
|
+
this.defer(record, MILESTONE_WAIT_MS);
|
|
41
|
+
if (taken === "resumed" || taken === "wait")
|
|
42
|
+
return;
|
|
27
43
|
const mine = batch.filter((record) => !this.delivering.has(this.kind.id(record)));
|
|
28
44
|
if (mine.length === 0)
|
|
29
45
|
return;
|
|
@@ -128,8 +144,8 @@ export class Outbox {
|
|
|
128
144
|
return true;
|
|
129
145
|
}
|
|
130
146
|
/** Busy target: try again shortly, and do not count it. */
|
|
131
|
-
defer(record) {
|
|
132
|
-
record.callbackNextAttemptAt = Date.now() +
|
|
147
|
+
defer(record, ms = 1000) {
|
|
148
|
+
record.callbackNextAttemptAt = Date.now() + ms;
|
|
133
149
|
this.kind.save(record);
|
|
134
150
|
}
|
|
135
151
|
/** Backs off like a failure, so an input never recorded is re-sent on a
|
|
@@ -142,10 +158,7 @@ export class Outbox {
|
|
|
142
158
|
this.kind.save(record);
|
|
143
159
|
}
|
|
144
160
|
delivered(record) {
|
|
145
|
-
record
|
|
146
|
-
record.callbackError = null;
|
|
147
|
-
record.callbackNextAttemptAt = null;
|
|
148
|
-
this.write(record);
|
|
161
|
+
this.write(Object.assign(record, DELIVERED));
|
|
149
162
|
}
|
|
150
163
|
/** A failed pass costs one attempt, whether it died before or after send. */
|
|
151
164
|
failed(record, error, counted) {
|