pi-webdesk 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/README.md +111 -0
- package/dist/apps/daemon/src/appearance-preferences.js +218 -0
- package/dist/apps/daemon/src/auth.js +88 -0
- package/dist/apps/daemon/src/bin.js +123 -0
- package/dist/apps/daemon/src/cli.js +48 -0
- package/dist/apps/daemon/src/event-hub.js +155 -0
- package/dist/apps/daemon/src/index.js +102 -0
- package/dist/apps/daemon/src/launcher-control.js +114 -0
- package/dist/apps/daemon/src/launcher.js +73 -0
- package/dist/apps/daemon/src/pi-auth.js +290 -0
- package/dist/apps/daemon/src/pi-resources.js +182 -0
- package/dist/apps/daemon/src/pi-runtime-factory.js +19 -0
- package/dist/apps/daemon/src/pi-sessions.js +265 -0
- package/dist/apps/daemon/src/runtime-process.js +241 -0
- package/dist/apps/daemon/src/secret.js +71 -0
- package/dist/apps/daemon/src/server.js +1662 -0
- package/dist/apps/daemon/src/session-projection.js +117 -0
- package/dist/apps/daemon/src/state-lock.js +31 -0
- package/dist/apps/daemon/src/static-web.js +53 -0
- package/dist/apps/daemon/src/task-archive.js +152 -0
- package/dist/apps/daemon/src/task-commit.js +503 -0
- package/dist/apps/daemon/src/task-merge.js +912 -0
- package/dist/apps/daemon/src/task-review.js +204 -0
- package/dist/apps/daemon/src/task-runtime.js +1124 -0
- package/dist/apps/daemon/src/task-validation.js +352 -0
- package/dist/apps/daemon/src/workspace-store.js +140 -0
- package/dist/apps/daemon/src/workspace.js +795 -0
- package/dist/extensions/webdesk.js +34 -0
- package/dist/packages/git/src/commit.js +675 -0
- package/dist/packages/git/src/errors.js +55 -0
- package/dist/packages/git/src/fingerprint.js +286 -0
- package/dist/packages/git/src/index.js +123 -0
- package/dist/packages/git/src/merge.js +1008 -0
- package/dist/packages/git/src/paths.js +58 -0
- package/dist/packages/git/src/repository.js +77 -0
- package/dist/packages/git/src/review.js +396 -0
- package/dist/packages/git/src/runner.js +110 -0
- package/dist/packages/git/src/validation.js +263 -0
- package/dist/packages/git/src/worktree.js +233 -0
- package/dist/packages/pi-bridge/extensions/pita-policy.js +117 -0
- package/dist/packages/pi-bridge/src/auth.js +80 -0
- package/dist/packages/pi-bridge/src/errors.js +19 -0
- package/dist/packages/pi-bridge/src/handshake.js +43 -0
- package/dist/packages/pi-bridge/src/index.js +76 -0
- package/dist/packages/pi-bridge/src/jsonl.js +105 -0
- package/dist/packages/pi-bridge/src/policy-approval.js +62 -0
- package/dist/packages/pi-bridge/src/resolve.js +59 -0
- package/dist/packages/pi-bridge/src/resources-child.mjs +23 -0
- package/dist/packages/pi-bridge/src/resources.js +481 -0
- package/dist/packages/pi-bridge/src/rpc/client.js +480 -0
- package/dist/packages/pi-bridge/src/rpc/runtime.js +496 -0
- package/dist/packages/pi-bridge/src/rpc/supervisor.mjs +129 -0
- package/dist/packages/pi-bridge/src/rpc/tool-events.js +78 -0
- package/dist/packages/pi-bridge/src/rpc/wire.js +263 -0
- package/dist/packages/pi-bridge/src/runtime.js +0 -0
- package/dist/packages/pi-bridge/src/sessions-child.mjs +38 -0
- package/dist/packages/pi-bridge/src/sessions.js +314 -0
- package/dist/packages/pi-bridge/src/tool-activity.js +56 -0
- package/dist/packages/protocol/src/index.js +1863 -0
- package/dist/web/assets/index-BOw_fhvO.css +2 -0
- package/dist/web/assets/index-oXs7yAAo.js +119 -0
- package/dist/web/index.html +14 -0
- package/package.json +69 -0
- package/scripts/prepare.mjs +7 -0
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
// apps/daemon/src/task-runtime.ts
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
isPiBridgeError
|
|
7
|
+
} from "../../../packages/pi-bridge/src/index.js";
|
|
8
|
+
import {
|
|
9
|
+
PROTOCOL_VERSION,
|
|
10
|
+
createDaemonEventFactory,
|
|
11
|
+
taskRuntimeSnapshotSchema,
|
|
12
|
+
taskRuntimeStatusSchema,
|
|
13
|
+
taskSessionTreeSchema
|
|
14
|
+
} from "../../../packages/protocol/src/index.js";
|
|
15
|
+
import { buildSessionTree, buildTranscript } from "./session-projection.js";
|
|
16
|
+
var TaskRuntimeOperationError = class extends Error {
|
|
17
|
+
name = "TaskRuntimeOperationError";
|
|
18
|
+
code;
|
|
19
|
+
status;
|
|
20
|
+
constructor(code, message, status, options) {
|
|
21
|
+
super(message, options);
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.status = status;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
var DETAIL_LIMIT = 1e3;
|
|
27
|
+
function clip(text) {
|
|
28
|
+
return text.length > DETAIL_LIMIT ? `${text.slice(0, DETAIL_LIMIT - 1)}\u2026` : text;
|
|
29
|
+
}
|
|
30
|
+
function errorMessage(error) {
|
|
31
|
+
return error instanceof Error ? error.message : String(error);
|
|
32
|
+
}
|
|
33
|
+
var MAX_APPROVAL_TIMEOUT_MS = 10 * 6e4;
|
|
34
|
+
var MAX_APPROVAL_TIMEOUT_MARGIN_MS = 5e3;
|
|
35
|
+
function createTaskRuntimeCoordinator(options) {
|
|
36
|
+
const now = options.now ?? Date.now;
|
|
37
|
+
const entries = /* @__PURE__ */ new Map();
|
|
38
|
+
const startsInFlight = /* @__PURE__ */ new Map();
|
|
39
|
+
const runtimeCleanups = /* @__PURE__ */ new Map();
|
|
40
|
+
const uncertainRuntimeCleanups = /* @__PURE__ */ new Set();
|
|
41
|
+
const activeRuntimeRequests = /* @__PURE__ */ new Map();
|
|
42
|
+
const exclusiveRuntimeRequests = /* @__PURE__ */ new Set();
|
|
43
|
+
const archiveStops = /* @__PURE__ */ new Set();
|
|
44
|
+
const lastStatuses = /* @__PURE__ */ new Map();
|
|
45
|
+
let disposed = false;
|
|
46
|
+
function statusOf(entry) {
|
|
47
|
+
return taskRuntimeStatusSchema.parse({
|
|
48
|
+
protocol: PROTOCOL_VERSION,
|
|
49
|
+
type: "task-runtime",
|
|
50
|
+
taskId: entry.taskId,
|
|
51
|
+
runtimeGeneration: entry.generation,
|
|
52
|
+
lastSequence: entry.lastSequence,
|
|
53
|
+
state: entry.state,
|
|
54
|
+
working: entry.working,
|
|
55
|
+
approval: entry.pendingApproval?.approval ?? null,
|
|
56
|
+
model: entry.model,
|
|
57
|
+
...entry.detail === null ? {} : { detail: clip(entry.detail) }
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function publishStatus(entry) {
|
|
61
|
+
const event = entry.emit("runtime-status", {
|
|
62
|
+
state: entry.state,
|
|
63
|
+
model: entry.model,
|
|
64
|
+
...entry.detail === null ? {} : { detail: clip(entry.detail) }
|
|
65
|
+
});
|
|
66
|
+
lastStatuses.set(entry.taskId, statusOf(entry));
|
|
67
|
+
options.publish(event);
|
|
68
|
+
}
|
|
69
|
+
function publishApprovalResolved(entry, pending, resolution) {
|
|
70
|
+
const event = entry.emit("approval-resolved", {
|
|
71
|
+
approvalId: pending.approval.approvalId,
|
|
72
|
+
resolution
|
|
73
|
+
});
|
|
74
|
+
lastStatuses.set(entry.taskId, statusOf(entry));
|
|
75
|
+
options.publish(event);
|
|
76
|
+
}
|
|
77
|
+
function detachPendingApproval(entry) {
|
|
78
|
+
const pending = entry.pendingApproval;
|
|
79
|
+
if (pending === null) return null;
|
|
80
|
+
entry.pendingApproval = null;
|
|
81
|
+
if (pending.timer !== null) clearTimeout(pending.timer);
|
|
82
|
+
pending.timer = null;
|
|
83
|
+
return pending;
|
|
84
|
+
}
|
|
85
|
+
function expirePendingApproval(entry, pending) {
|
|
86
|
+
if (entry.removed || entry.pendingApproval !== pending) return;
|
|
87
|
+
detachPendingApproval(entry);
|
|
88
|
+
try {
|
|
89
|
+
entry.runtime.respondToDialog({
|
|
90
|
+
requestId: pending.requestId,
|
|
91
|
+
kind: "confirm",
|
|
92
|
+
confirmed: false
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
options.publish(
|
|
96
|
+
entry.emit("log", {
|
|
97
|
+
level: "error",
|
|
98
|
+
message: `Failed to deliver approval timeout ${pending.approval.approvalId}: ${errorMessage(error)}`
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
publishApprovalResolved(entry, pending, "timeout");
|
|
103
|
+
}
|
|
104
|
+
function supersedePendingApproval(entry, respond) {
|
|
105
|
+
const pending = detachPendingApproval(entry);
|
|
106
|
+
if (pending === null) return;
|
|
107
|
+
if (respond) {
|
|
108
|
+
try {
|
|
109
|
+
entry.runtime.respondToDialog({
|
|
110
|
+
requestId: pending.requestId,
|
|
111
|
+
kind: "confirm",
|
|
112
|
+
confirmed: false
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
options.publish(
|
|
116
|
+
entry.emit("log", {
|
|
117
|
+
level: "error",
|
|
118
|
+
message: `Failed to deny superseded approval ${pending.approval.approvalId}: ${errorMessage(error)}`
|
|
119
|
+
})
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
publishApprovalResolved(entry, pending, "superseded");
|
|
124
|
+
}
|
|
125
|
+
function removeEntry(entry, respondToApproval = true) {
|
|
126
|
+
supersedePendingApproval(entry, respondToApproval);
|
|
127
|
+
entry.removed = true;
|
|
128
|
+
entry.unsubscribe();
|
|
129
|
+
if (entries.get(entry.taskId) === entry) {
|
|
130
|
+
entries.delete(entry.taskId);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function trackRuntimeCleanup(entry) {
|
|
134
|
+
const existing = runtimeCleanups.get(entry);
|
|
135
|
+
if (existing !== void 0) return existing;
|
|
136
|
+
const cleanup = (async () => {
|
|
137
|
+
try {
|
|
138
|
+
await entry.runtime.dispose();
|
|
139
|
+
} catch (error) {
|
|
140
|
+
options.publish(
|
|
141
|
+
entry.emit("log", {
|
|
142
|
+
level: "error",
|
|
143
|
+
message: clip(
|
|
144
|
+
`Pi runtime cleanup failed; durable recovery ownership was retained: ${errorMessage(error)}`
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
if (entry.processRecording !== null) {
|
|
151
|
+
try {
|
|
152
|
+
await entry.processRecording;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
options.publish(
|
|
155
|
+
entry.emit("log", {
|
|
156
|
+
level: "error",
|
|
157
|
+
message: clip(
|
|
158
|
+
`Pi runtime ownership recording did not settle cleanly; the lease was retained: ${errorMessage(error)}`
|
|
159
|
+
)
|
|
160
|
+
})
|
|
161
|
+
);
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (entry.processPid !== null && options.clearRuntimeProcess !== void 0) {
|
|
166
|
+
try {
|
|
167
|
+
await options.clearRuntimeProcess(entry.taskId, entry.generation, entry.processPid);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
options.publish(
|
|
170
|
+
entry.emit("log", {
|
|
171
|
+
level: "error",
|
|
172
|
+
message: clip(
|
|
173
|
+
`Pi stopped, but its durable recovery lease could not be cleared: ${errorMessage(error)}`
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
);
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
})();
|
|
182
|
+
runtimeCleanups.set(entry, cleanup);
|
|
183
|
+
void cleanup.then((clean) => {
|
|
184
|
+
if (!clean) uncertainRuntimeCleanups.add(entry);
|
|
185
|
+
if (runtimeCleanups.get(entry) === cleanup) {
|
|
186
|
+
runtimeCleanups.delete(entry);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
return cleanup;
|
|
190
|
+
}
|
|
191
|
+
async function waitForTaskCleanups(taskId) {
|
|
192
|
+
const results = await Promise.all(
|
|
193
|
+
[...runtimeCleanups].filter(([entry]) => entry.taskId === taskId).map(([, cleanup]) => cleanup)
|
|
194
|
+
);
|
|
195
|
+
return results.every(Boolean) && ![...uncertainRuntimeCleanups].some((entry) => entry.taskId === taskId);
|
|
196
|
+
}
|
|
197
|
+
function publishCleanupUncertain(taskId, preferred) {
|
|
198
|
+
const entry = preferred ?? [...uncertainRuntimeCleanups].find((candidate) => candidate.taskId === taskId);
|
|
199
|
+
if (entry === void 0) return;
|
|
200
|
+
entry.state = "failed";
|
|
201
|
+
entry.working = false;
|
|
202
|
+
entry.detail = "Pi runtime cleanup could not be proven. Restart Webdesk to run durable process recovery before starting or archiving this task.";
|
|
203
|
+
publishStatus(entry);
|
|
204
|
+
}
|
|
205
|
+
function requireCleanupProven(taskId, clean, entry) {
|
|
206
|
+
if (!clean) {
|
|
207
|
+
publishCleanupUncertain(taskId, entry);
|
|
208
|
+
throw new TaskRuntimeOperationError(
|
|
209
|
+
"runtime-recovery-required",
|
|
210
|
+
"A previous Pi runtime cleanup could not be proven. Restart Webdesk to run durable process recovery before starting this task again.",
|
|
211
|
+
409
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function assertTaskNotArchiving(taskId) {
|
|
216
|
+
if (options.isTaskArchiving?.(taskId) === true || archiveStops.has(taskId)) {
|
|
217
|
+
throw new TaskRuntimeOperationError(
|
|
218
|
+
"runtime-busy",
|
|
219
|
+
"This task is being archived or restored. Wait for that operation to finish.",
|
|
220
|
+
409
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function trackRuntimeRequest(taskId, operation, exclusive = true) {
|
|
225
|
+
assertTaskNotArchiving(taskId);
|
|
226
|
+
if (exclusive && activeRuntimeRequests.has(taskId)) {
|
|
227
|
+
throw new TaskRuntimeOperationError(
|
|
228
|
+
"runtime-busy",
|
|
229
|
+
"Another Pi runtime request is already in progress for this task.",
|
|
230
|
+
409
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (exclusive) exclusiveRuntimeRequests.add(taskId);
|
|
234
|
+
activeRuntimeRequests.set(taskId, (activeRuntimeRequests.get(taskId) ?? 0) + 1);
|
|
235
|
+
try {
|
|
236
|
+
return await operation();
|
|
237
|
+
} finally {
|
|
238
|
+
if (exclusive) exclusiveRuntimeRequests.delete(taskId);
|
|
239
|
+
const remaining = (activeRuntimeRequests.get(taskId) ?? 1) - 1;
|
|
240
|
+
if (remaining === 0) activeRuntimeRequests.delete(taskId);
|
|
241
|
+
else activeRuntimeRequests.set(taskId, remaining);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function cleanUpFailedRuntime(entry) {
|
|
245
|
+
if (entry.removed) return;
|
|
246
|
+
removeEntry(entry);
|
|
247
|
+
void trackRuntimeCleanup(entry);
|
|
248
|
+
}
|
|
249
|
+
function handleDialogRequest(entry, request) {
|
|
250
|
+
if (request.kind === "approval") {
|
|
251
|
+
if (entry.pendingApproval !== null) {
|
|
252
|
+
try {
|
|
253
|
+
entry.runtime.respondToDialog({
|
|
254
|
+
requestId: request.requestId,
|
|
255
|
+
kind: "confirm",
|
|
256
|
+
confirmed: false
|
|
257
|
+
});
|
|
258
|
+
} catch {
|
|
259
|
+
}
|
|
260
|
+
options.publish(
|
|
261
|
+
entry.emit("log", {
|
|
262
|
+
level: "error",
|
|
263
|
+
message: `Refused overlapping ${request.toolName} approval for tool call ${request.toolCallId}; only one decision may be pending per task runtime.`
|
|
264
|
+
})
|
|
265
|
+
);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const receivedAtMs = now();
|
|
269
|
+
const requestedAtMs = Math.min(request.requestedAtMs, receivedAtMs);
|
|
270
|
+
const upstreamTimeoutMs = Math.min(request.timeoutMs, MAX_APPROVAL_TIMEOUT_MS);
|
|
271
|
+
const timeoutMarginMs = Math.min(
|
|
272
|
+
MAX_APPROVAL_TIMEOUT_MARGIN_MS,
|
|
273
|
+
Math.max(1, Math.floor(upstreamTimeoutMs * 0.05))
|
|
274
|
+
);
|
|
275
|
+
const expiresAtMs = requestedAtMs + Math.max(1, upstreamTimeoutMs - timeoutMarginMs);
|
|
276
|
+
const timeoutMs = expiresAtMs - receivedAtMs;
|
|
277
|
+
if (timeoutMs <= 0) {
|
|
278
|
+
try {
|
|
279
|
+
entry.runtime.respondToDialog({
|
|
280
|
+
requestId: request.requestId,
|
|
281
|
+
kind: "confirm",
|
|
282
|
+
confirmed: false
|
|
283
|
+
});
|
|
284
|
+
} catch {
|
|
285
|
+
}
|
|
286
|
+
options.publish(
|
|
287
|
+
entry.emit("log", {
|
|
288
|
+
level: "warning",
|
|
289
|
+
message: `Refused expired ${request.toolName} approval for tool call ${request.toolCallId}; the action remains blocked.`
|
|
290
|
+
})
|
|
291
|
+
);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const pending = {
|
|
295
|
+
approval: {
|
|
296
|
+
approvalId: randomUUID(),
|
|
297
|
+
toolCallId: request.toolCallId,
|
|
298
|
+
toolName: request.toolName,
|
|
299
|
+
summary: request.summary,
|
|
300
|
+
requestedAtMs,
|
|
301
|
+
expiresAtMs
|
|
302
|
+
},
|
|
303
|
+
requestId: request.requestId,
|
|
304
|
+
timer: null
|
|
305
|
+
};
|
|
306
|
+
entry.pendingApproval = pending;
|
|
307
|
+
options.publish(entry.emit("approval-requested", pending.approval));
|
|
308
|
+
pending.timer = setTimeout(() => {
|
|
309
|
+
expirePendingApproval(entry, pending);
|
|
310
|
+
}, timeoutMs);
|
|
311
|
+
pending.timer.unref?.();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
entry.runtime.respondToDialog({
|
|
316
|
+
requestId: request.requestId,
|
|
317
|
+
kind: "cancel"
|
|
318
|
+
});
|
|
319
|
+
options.publish(
|
|
320
|
+
entry.emit("log", {
|
|
321
|
+
level: "warning",
|
|
322
|
+
message: `Cancelled unsupported Pi ${request.kind} dialog ("${request.title}"): only versioned Webdesk policy approvals may reach the browser.`
|
|
323
|
+
})
|
|
324
|
+
);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
options.publish(
|
|
327
|
+
entry.emit("log", {
|
|
328
|
+
level: "error",
|
|
329
|
+
message: `Failed to cancel Pi dialog ${request.requestId}: ${errorMessage(error)}`
|
|
330
|
+
})
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function handleRuntimeEvent(entry, event) {
|
|
335
|
+
if (entry.removed) return;
|
|
336
|
+
switch (event.kind) {
|
|
337
|
+
case "lifecycle":
|
|
338
|
+
if (event.state === "failed" && entry.state === "ready" && !entry.removed) {
|
|
339
|
+
entry.state = "failed";
|
|
340
|
+
entry.working = false;
|
|
341
|
+
entry.detail = "Pi reported a fatal runtime failure.";
|
|
342
|
+
publishStatus(entry);
|
|
343
|
+
queueMicrotask(() => cleanUpFailedRuntime(entry));
|
|
344
|
+
}
|
|
345
|
+
return;
|
|
346
|
+
case "assistant-delta":
|
|
347
|
+
options.publish(entry.emit("assistant-delta", { text: event.text }));
|
|
348
|
+
return;
|
|
349
|
+
case "tool-activity":
|
|
350
|
+
options.publish(entry.emit("tool-activity", event.tool));
|
|
351
|
+
return;
|
|
352
|
+
case "agent-activity":
|
|
353
|
+
entry.activityRevision++;
|
|
354
|
+
if (event.phase === "agent-start" || event.phase === "turn-start") {
|
|
355
|
+
entry.working = true;
|
|
356
|
+
} else if (event.phase === "agent-end" || event.phase === "agent-settled") {
|
|
357
|
+
entry.working = false;
|
|
358
|
+
}
|
|
359
|
+
options.publish(entry.emit("agent-activity", { phase: event.phase }));
|
|
360
|
+
return;
|
|
361
|
+
case "notification":
|
|
362
|
+
options.publish(entry.emit("log", { level: event.level, message: event.message }));
|
|
363
|
+
return;
|
|
364
|
+
case "extension-error":
|
|
365
|
+
options.publish(
|
|
366
|
+
entry.emit("log", {
|
|
367
|
+
level: "error",
|
|
368
|
+
message: `Pi extension error: ${event.message}`
|
|
369
|
+
})
|
|
370
|
+
);
|
|
371
|
+
return;
|
|
372
|
+
case "protocol-issue":
|
|
373
|
+
options.publish(
|
|
374
|
+
entry.emit("log", {
|
|
375
|
+
level: event.fatal ? "error" : "warning",
|
|
376
|
+
message: `Pi protocol issue: ${event.message}`
|
|
377
|
+
})
|
|
378
|
+
);
|
|
379
|
+
if (event.fatal && entry.state === "failed") {
|
|
380
|
+
entry.detail = `Pi protocol failure: ${event.message}`;
|
|
381
|
+
publishStatus(entry);
|
|
382
|
+
cleanUpFailedRuntime(entry);
|
|
383
|
+
}
|
|
384
|
+
return;
|
|
385
|
+
case "policy-status":
|
|
386
|
+
return;
|
|
387
|
+
case "dialog-requested":
|
|
388
|
+
handleDialogRequest(entry, event.request);
|
|
389
|
+
return;
|
|
390
|
+
case "exited": {
|
|
391
|
+
const interrupted = entry.working || entry.pendingApproval !== null;
|
|
392
|
+
entry.working = false;
|
|
393
|
+
entry.detail = `Pi exited (code ${event.code}, signal ${event.signal ?? "null"})`;
|
|
394
|
+
if (entry.state === "starting") {
|
|
395
|
+
entry.startupExit = entry.detail;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (entry.state !== "failed") entry.state = interrupted ? "failed" : "exited";
|
|
399
|
+
publishStatus(entry);
|
|
400
|
+
removeEntry(entry, false);
|
|
401
|
+
void trackRuntimeCleanup(entry);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async function verifyRuntimeWorktree(task) {
|
|
407
|
+
try {
|
|
408
|
+
if (options.validateWorktree !== void 0) return await options.validateWorktree(task);
|
|
409
|
+
const worktree = await stat(task.worktreePath);
|
|
410
|
+
if (!worktree.isDirectory()) throw new Error("not a directory");
|
|
411
|
+
return task.worktreePath;
|
|
412
|
+
} catch (error) {
|
|
413
|
+
throw new TaskRuntimeOperationError(
|
|
414
|
+
"task-not-ready",
|
|
415
|
+
`The task worktree is unavailable at ${task.worktreePath}. Recover the worktree before starting Pi.`,
|
|
416
|
+
409,
|
|
417
|
+
{ cause: error }
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async function startRuntime(taskId) {
|
|
422
|
+
const task = await options.getTask(taskId);
|
|
423
|
+
if (disposed) {
|
|
424
|
+
throw new TaskRuntimeOperationError(
|
|
425
|
+
"runtime-unavailable",
|
|
426
|
+
"The daemon is shutting down.",
|
|
427
|
+
503
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
if (task === null) {
|
|
431
|
+
throw new TaskRuntimeOperationError(
|
|
432
|
+
"task-not-found",
|
|
433
|
+
"The selected task no longer exists.",
|
|
434
|
+
404
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
if (task.status !== "ready") {
|
|
438
|
+
throw new TaskRuntimeOperationError(
|
|
439
|
+
"task-not-ready",
|
|
440
|
+
`Only a ready task can start a runtime; this task is ${task.status}.`,
|
|
441
|
+
409
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
let verifiedWorktreePath = await verifyRuntimeWorktree(task);
|
|
445
|
+
if (disposed) {
|
|
446
|
+
throw new TaskRuntimeOperationError(
|
|
447
|
+
"runtime-unavailable",
|
|
448
|
+
"The daemon is shutting down.",
|
|
449
|
+
503
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
if (options.reconcileRuntimeProcess !== void 0) {
|
|
453
|
+
try {
|
|
454
|
+
await options.reconcileRuntimeProcess(taskId);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
throw new TaskRuntimeOperationError(
|
|
457
|
+
"runtime-recovery-required",
|
|
458
|
+
`Webdesk could not safely reconcile the previous Pi runtime: ${errorMessage(error)} Stop any remaining task processes, then choose Start Pi to retry.`,
|
|
459
|
+
409,
|
|
460
|
+
{ cause: error }
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
let generation;
|
|
465
|
+
try {
|
|
466
|
+
generation = await options.claimRuntimeGeneration(taskId);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
throw new TaskRuntimeOperationError(
|
|
469
|
+
"runtime-unavailable",
|
|
470
|
+
`Webdesk could not reserve a runtime generation: ${errorMessage(error)}`,
|
|
471
|
+
503,
|
|
472
|
+
{ cause: error }
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
if (disposed) {
|
|
476
|
+
throw new TaskRuntimeOperationError(
|
|
477
|
+
"runtime-unavailable",
|
|
478
|
+
"The daemon is shutting down.",
|
|
479
|
+
503
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
verifiedWorktreePath = await verifyRuntimeWorktree(task);
|
|
483
|
+
if (disposed) {
|
|
484
|
+
throw new TaskRuntimeOperationError(
|
|
485
|
+
"runtime-unavailable",
|
|
486
|
+
"The daemon is shutting down.",
|
|
487
|
+
503
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
let runtime;
|
|
491
|
+
try {
|
|
492
|
+
runtime = options.createRuntime({
|
|
493
|
+
taskId,
|
|
494
|
+
worktreePath: verifiedWorktreePath,
|
|
495
|
+
sessionFile: task.piSessionFile ?? null
|
|
496
|
+
});
|
|
497
|
+
} catch (error) {
|
|
498
|
+
throw new TaskRuntimeOperationError(
|
|
499
|
+
"runtime-start-failed",
|
|
500
|
+
`The Pi runtime could not be created: ${errorMessage(error)}`,
|
|
501
|
+
502,
|
|
502
|
+
{ cause: error }
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
const rawEmit = createDaemonEventFactory({
|
|
506
|
+
taskId,
|
|
507
|
+
runtimeGeneration: generation,
|
|
508
|
+
...options.now === void 0 ? {} : { now: options.now }
|
|
509
|
+
});
|
|
510
|
+
let entry;
|
|
511
|
+
entry = {
|
|
512
|
+
taskId,
|
|
513
|
+
generation,
|
|
514
|
+
runtime,
|
|
515
|
+
state: "starting",
|
|
516
|
+
working: false,
|
|
517
|
+
activityRevision: 0,
|
|
518
|
+
startupExit: null,
|
|
519
|
+
detail: null,
|
|
520
|
+
model: null,
|
|
521
|
+
lastSequence: null,
|
|
522
|
+
pendingApproval: null,
|
|
523
|
+
processPid: null,
|
|
524
|
+
processRecording: null,
|
|
525
|
+
removed: false,
|
|
526
|
+
unsubscribe: () => {
|
|
527
|
+
},
|
|
528
|
+
emit(type, payload) {
|
|
529
|
+
const event = rawEmit(type, payload);
|
|
530
|
+
entry.lastSequence = event.sequence;
|
|
531
|
+
return event;
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
const competingEntry = entries.get(taskId);
|
|
535
|
+
if (competingEntry !== void 0 && !competingEntry.removed) {
|
|
536
|
+
await runtime.dispose().catch(() => void 0);
|
|
537
|
+
throw new TaskRuntimeOperationError(
|
|
538
|
+
"runtime-busy",
|
|
539
|
+
"Another Pi runtime became active while this task was starting.",
|
|
540
|
+
409
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
entry.unsubscribe = runtime.subscribe((event) => handleRuntimeEvent(entry, event));
|
|
544
|
+
entries.set(taskId, entry);
|
|
545
|
+
publishStatus(entry);
|
|
546
|
+
try {
|
|
547
|
+
const handshake = await runtime.start({
|
|
548
|
+
onProcessStarted: async (pid) => {
|
|
549
|
+
entry.processPid = pid;
|
|
550
|
+
const recording = options.recordRuntimeProcess?.(taskId, generation, pid) ?? Promise.resolve();
|
|
551
|
+
entry.processRecording = recording;
|
|
552
|
+
await recording;
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
assertRuntimeInitializing(entry);
|
|
556
|
+
const piState = await runtime.getState();
|
|
557
|
+
assertRuntimeInitializing(entry);
|
|
558
|
+
if (piState.sessionFile === null || !path.isAbsolute(piState.sessionFile)) {
|
|
559
|
+
throw new Error("Pi did not report an absolute persistent session file for this task.");
|
|
560
|
+
}
|
|
561
|
+
const sessionFile = path.normalize(piState.sessionFile);
|
|
562
|
+
if (task.piSessionFile !== void 0 && path.normalize(task.piSessionFile) !== sessionFile) {
|
|
563
|
+
throw new Error(
|
|
564
|
+
"Pi resumed a different session file than the one associated with this task."
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
if (task.piSessionFile === void 0) {
|
|
568
|
+
await options.recordTaskSession(taskId, sessionFile);
|
|
569
|
+
assertRuntimeInitializing(entry);
|
|
570
|
+
}
|
|
571
|
+
assertRuntimeInitializing(entry);
|
|
572
|
+
entry.model = normalizeModel(piState.model);
|
|
573
|
+
const authenticated = entry.model === null || options.isProviderAuthenticated === void 0 ? true : await options.isProviderAuthenticated(entry.model.provider);
|
|
574
|
+
assertRuntimeInitializing(entry);
|
|
575
|
+
entry.state = authenticated ? "ready" : "needs_auth";
|
|
576
|
+
entry.detail = authenticated ? `pita-policy v${handshake.version}, ${handshake.mode} mode` : `Sign in to ${entry.model?.provider ?? "the selected Pi provider"} before sending a prompt.`;
|
|
577
|
+
publishStatus(entry);
|
|
578
|
+
return statusOf(entry);
|
|
579
|
+
} catch (error) {
|
|
580
|
+
const message = errorMessage(error);
|
|
581
|
+
const removedBeforeFailure = entry.removed;
|
|
582
|
+
if (!entry.removed) {
|
|
583
|
+
entry.state = "failed";
|
|
584
|
+
entry.detail = message;
|
|
585
|
+
publishStatus(entry);
|
|
586
|
+
removeEntry(entry);
|
|
587
|
+
}
|
|
588
|
+
if (!removedBeforeFailure) await trackRuntimeCleanup(entry);
|
|
589
|
+
if (error instanceof TaskRuntimeOperationError) throw error;
|
|
590
|
+
throw new TaskRuntimeOperationError(
|
|
591
|
+
"runtime-start-failed",
|
|
592
|
+
`The Pi runtime did not become ready: ${message}`,
|
|
593
|
+
502,
|
|
594
|
+
{ cause: error }
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function assertRuntimeInitializing(entry) {
|
|
599
|
+
if (disposed || entry.removed) {
|
|
600
|
+
throw new TaskRuntimeOperationError(
|
|
601
|
+
"runtime-unavailable",
|
|
602
|
+
"The daemon is shutting down.",
|
|
603
|
+
503
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
if (entry.startupExit !== null) {
|
|
607
|
+
throw new Error(entry.startupExit);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
async function inspectRuntime(taskId) {
|
|
611
|
+
const task = await options.getTask(taskId);
|
|
612
|
+
requireNotDisposed();
|
|
613
|
+
if (task === null) {
|
|
614
|
+
throw new TaskRuntimeOperationError(
|
|
615
|
+
"task-not-found",
|
|
616
|
+
"The selected task no longer exists.",
|
|
617
|
+
404
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
if (task.status !== "ready") {
|
|
621
|
+
throw new TaskRuntimeOperationError(
|
|
622
|
+
"task-not-ready",
|
|
623
|
+
`Only a ready task can own a runtime; this task is ${task.status}.`,
|
|
624
|
+
409
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
const entry = entries.get(taskId);
|
|
628
|
+
if (entry !== void 0) return statusOf(entry);
|
|
629
|
+
const previous = lastStatuses.get(taskId);
|
|
630
|
+
if (previous !== void 0) return previous;
|
|
631
|
+
return taskRuntimeStatusSchema.parse({
|
|
632
|
+
protocol: PROTOCOL_VERSION,
|
|
633
|
+
type: "task-runtime",
|
|
634
|
+
taskId,
|
|
635
|
+
runtimeGeneration: task.runtimeGeneration ?? 0,
|
|
636
|
+
lastSequence: null,
|
|
637
|
+
state: "idle",
|
|
638
|
+
working: false,
|
|
639
|
+
model: null
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
async function offlineSnapshot(taskId) {
|
|
643
|
+
const status = await inspectRuntime(taskId);
|
|
644
|
+
return taskRuntimeSnapshotSchema.parse({
|
|
645
|
+
protocol: PROTOCOL_VERSION,
|
|
646
|
+
type: "task-runtime-snapshot",
|
|
647
|
+
taskId,
|
|
648
|
+
runtimeGeneration: status.runtimeGeneration,
|
|
649
|
+
lastSequence: status.lastSequence,
|
|
650
|
+
state: status.state,
|
|
651
|
+
working: status.working,
|
|
652
|
+
approval: status.approval,
|
|
653
|
+
model: status.model ?? null,
|
|
654
|
+
...status.detail === void 0 ? {} : { detail: status.detail },
|
|
655
|
+
transcript: null,
|
|
656
|
+
transcriptUnavailableReason: "runtime-not-live",
|
|
657
|
+
omittedTranscriptItems: 0,
|
|
658
|
+
sessionTree: null,
|
|
659
|
+
inFlightGap: status.working
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
async function captureSnapshot(taskId) {
|
|
663
|
+
const entry = entries.get(taskId);
|
|
664
|
+
if (entry === void 0 || entry.removed) return offlineSnapshot(taskId);
|
|
665
|
+
let piEntries = null;
|
|
666
|
+
let piTree = null;
|
|
667
|
+
let unavailableReason;
|
|
668
|
+
let captureStable = false;
|
|
669
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
670
|
+
const cursorBefore = entry.lastSequence;
|
|
671
|
+
try {
|
|
672
|
+
piEntries = await entry.runtime.getEntries();
|
|
673
|
+
unavailableReason = void 0;
|
|
674
|
+
} catch (error) {
|
|
675
|
+
piEntries = null;
|
|
676
|
+
unavailableReason = isPiBridgeError(error, "PI_RUNTIME_NOT_READY") ? "runtime-not-live" : "pi-request-failed";
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
try {
|
|
680
|
+
piTree = await entry.runtime.getTree();
|
|
681
|
+
} catch {
|
|
682
|
+
piTree = null;
|
|
683
|
+
}
|
|
684
|
+
if (entry.removed) break;
|
|
685
|
+
if (entry.lastSequence === cursorBefore) {
|
|
686
|
+
captureStable = true;
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (entry.removed) return offlineSnapshot(taskId);
|
|
691
|
+
const status = statusOf(entry);
|
|
692
|
+
const transcript = piEntries === null ? null : buildTranscript(piEntries);
|
|
693
|
+
return taskRuntimeSnapshotSchema.parse({
|
|
694
|
+
protocol: PROTOCOL_VERSION,
|
|
695
|
+
type: "task-runtime-snapshot",
|
|
696
|
+
taskId,
|
|
697
|
+
runtimeGeneration: status.runtimeGeneration,
|
|
698
|
+
lastSequence: status.lastSequence,
|
|
699
|
+
state: status.state,
|
|
700
|
+
working: status.working,
|
|
701
|
+
approval: status.approval,
|
|
702
|
+
model: status.model ?? null,
|
|
703
|
+
...status.detail === void 0 ? {} : { detail: status.detail },
|
|
704
|
+
transcript: transcript?.items ?? null,
|
|
705
|
+
...unavailableReason === void 0 ? {} : { transcriptUnavailableReason: unavailableReason },
|
|
706
|
+
omittedTranscriptItems: transcript?.omittedItems ?? 0,
|
|
707
|
+
sessionTree: piTree === null ? null : buildSessionTree(piTree),
|
|
708
|
+
// A working runtime is streaming non-canonical output. Likewise, if all
|
|
709
|
+
// bounded attempts observed cursor movement, the transcript and cursor
|
|
710
|
+
// were not captured atomically even if the turn just settled.
|
|
711
|
+
inFlightGap: status.working || piEntries !== null && !captureStable
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
function requireLiveRuntime(taskId) {
|
|
715
|
+
const entry = entries.get(taskId);
|
|
716
|
+
if (entry === void 0 || entry.state !== "ready") {
|
|
717
|
+
throw new TaskRuntimeOperationError(
|
|
718
|
+
"runtime-not-ready",
|
|
719
|
+
"Start the task runtime and wait for it to become ready first.",
|
|
720
|
+
409
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
return entry;
|
|
724
|
+
}
|
|
725
|
+
function requireModelRuntime(taskId) {
|
|
726
|
+
const entry = entries.get(taskId);
|
|
727
|
+
if (entry === void 0 || entry.state !== "ready" && entry.state !== "needs_auth") {
|
|
728
|
+
throw new TaskRuntimeOperationError(
|
|
729
|
+
"runtime-not-ready",
|
|
730
|
+
"Start the task runtime and wait for Pi setup before selecting a model.",
|
|
731
|
+
409
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
return entry;
|
|
735
|
+
}
|
|
736
|
+
function requireNotDisposed() {
|
|
737
|
+
if (disposed) {
|
|
738
|
+
throw new TaskRuntimeOperationError(
|
|
739
|
+
"runtime-unavailable",
|
|
740
|
+
"The daemon is shutting down.",
|
|
741
|
+
503
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return {
|
|
746
|
+
async ensure(taskId) {
|
|
747
|
+
requireNotDisposed();
|
|
748
|
+
assertTaskNotArchiving(taskId);
|
|
749
|
+
const inFlight = startsInFlight.get(taskId);
|
|
750
|
+
if (inFlight !== void 0) return inFlight;
|
|
751
|
+
const entry = entries.get(taskId);
|
|
752
|
+
if (entry !== void 0 && entry.state !== "failed") return statusOf(entry);
|
|
753
|
+
if (entry?.state === "failed") cleanUpFailedRuntime(entry);
|
|
754
|
+
const start = waitForTaskCleanups(taskId).then((clean) => {
|
|
755
|
+
requireCleanupProven(taskId, clean);
|
|
756
|
+
return startRuntime(taskId);
|
|
757
|
+
}).finally(() => {
|
|
758
|
+
startsInFlight.delete(taskId);
|
|
759
|
+
});
|
|
760
|
+
startsInFlight.set(taskId, start);
|
|
761
|
+
return start;
|
|
762
|
+
},
|
|
763
|
+
status: inspectRuntime,
|
|
764
|
+
async snapshot(taskId) {
|
|
765
|
+
requireNotDisposed();
|
|
766
|
+
return captureSnapshot(taskId);
|
|
767
|
+
},
|
|
768
|
+
async sessionTree(taskId) {
|
|
769
|
+
requireNotDisposed();
|
|
770
|
+
const entry = entries.get(taskId);
|
|
771
|
+
if (entry === void 0 || entry.removed || entry.state !== "ready" && entry.state !== "needs_auth") {
|
|
772
|
+
throw new TaskRuntimeOperationError(
|
|
773
|
+
"runtime-not-ready",
|
|
774
|
+
"Start the task runtime to view its Pi session tree.",
|
|
775
|
+
409
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
try {
|
|
779
|
+
const tree = await entry.runtime.getTree();
|
|
780
|
+
return taskSessionTreeSchema.parse({
|
|
781
|
+
protocol: PROTOCOL_VERSION,
|
|
782
|
+
type: "task-session-tree",
|
|
783
|
+
taskId,
|
|
784
|
+
runtimeGeneration: entry.generation,
|
|
785
|
+
sessionTree: buildSessionTree(tree)
|
|
786
|
+
});
|
|
787
|
+
} catch (error) {
|
|
788
|
+
throw new TaskRuntimeOperationError(
|
|
789
|
+
"runtime-request-failed",
|
|
790
|
+
"Pi could not provide the session tree. Retry after the runtime settles.",
|
|
791
|
+
502,
|
|
792
|
+
{ cause: error }
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
},
|
|
796
|
+
steer(taskId, message) {
|
|
797
|
+
return trackRuntimeRequest(taskId, async () => {
|
|
798
|
+
requireNotDisposed();
|
|
799
|
+
const entry = requireLiveRuntime(taskId);
|
|
800
|
+
if (entry.pendingApproval !== null) {
|
|
801
|
+
throw new TaskRuntimeOperationError(
|
|
802
|
+
"runtime-busy",
|
|
803
|
+
"Resolve the pending tool approval before steering Pi.",
|
|
804
|
+
409
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
if (!entry.working) {
|
|
808
|
+
throw new TaskRuntimeOperationError(
|
|
809
|
+
"runtime-not-working",
|
|
810
|
+
"Pi is not working on a turn; send a prompt instead.",
|
|
811
|
+
409
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
try {
|
|
815
|
+
await entry.runtime.steer(message);
|
|
816
|
+
} catch (error) {
|
|
817
|
+
throw new TaskRuntimeOperationError(
|
|
818
|
+
"runtime-request-failed",
|
|
819
|
+
`Pi did not accept the steering message: ${errorMessage(error)}`,
|
|
820
|
+
502,
|
|
821
|
+
{ cause: error }
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
return statusOf(entry);
|
|
825
|
+
});
|
|
826
|
+
},
|
|
827
|
+
prompt(taskId, message) {
|
|
828
|
+
return trackRuntimeRequest(taskId, async () => {
|
|
829
|
+
requireNotDisposed();
|
|
830
|
+
if (options.isTaskLifecycleBusy?.(taskId) === true) {
|
|
831
|
+
throw new TaskRuntimeOperationError(
|
|
832
|
+
"runtime-busy",
|
|
833
|
+
"A validation, commit, or merge operation is active for this task. Wait for it to finish before prompting Pi.",
|
|
834
|
+
409
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
const candidate = entries.get(taskId);
|
|
838
|
+
if (candidate?.state === "needs_auth") {
|
|
839
|
+
throw new TaskRuntimeOperationError(
|
|
840
|
+
"runtime-auth-required",
|
|
841
|
+
`Sign in to ${candidate.model?.provider ?? "the selected Pi provider"}, then restart Pi before resending the prompt.`,
|
|
842
|
+
409
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
const entry = requireLiveRuntime(taskId);
|
|
846
|
+
if (entry.working) {
|
|
847
|
+
throw new TaskRuntimeOperationError(
|
|
848
|
+
"runtime-busy",
|
|
849
|
+
"Pi is already working. Abort the active turn or wait for it to finish.",
|
|
850
|
+
409
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
const activityBeforePrompt = entry.activityRevision;
|
|
854
|
+
entry.working = true;
|
|
855
|
+
try {
|
|
856
|
+
await entry.runtime.prompt(message);
|
|
857
|
+
} catch (error) {
|
|
858
|
+
if (entry.activityRevision === activityBeforePrompt) entry.working = false;
|
|
859
|
+
if (isAuthRequiredError(error)) {
|
|
860
|
+
entry.state = "needs_auth";
|
|
861
|
+
entry.detail = `Sign in to ${entry.model?.provider ?? "the selected Pi provider"}, then restart Pi.`;
|
|
862
|
+
publishStatus(entry);
|
|
863
|
+
throw new TaskRuntimeOperationError("runtime-auth-required", entry.detail, 409, {
|
|
864
|
+
cause: error
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
throw new TaskRuntimeOperationError(
|
|
868
|
+
"runtime-request-failed",
|
|
869
|
+
`Pi did not accept the prompt: ${errorMessage(error)}`,
|
|
870
|
+
502,
|
|
871
|
+
{ cause: error }
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
return statusOf(entry);
|
|
875
|
+
});
|
|
876
|
+
},
|
|
877
|
+
async restart(taskId) {
|
|
878
|
+
requireNotDisposed();
|
|
879
|
+
assertTaskNotArchiving(taskId);
|
|
880
|
+
if (startsInFlight.has(taskId)) {
|
|
881
|
+
throw new TaskRuntimeOperationError(
|
|
882
|
+
"runtime-busy",
|
|
883
|
+
"Wait for the current Pi startup to finish before restarting it.",
|
|
884
|
+
409
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
if (activeRuntimeRequests.has(taskId)) {
|
|
888
|
+
throw new TaskRuntimeOperationError(
|
|
889
|
+
"runtime-busy",
|
|
890
|
+
"Wait for the active Pi runtime request to finish before restarting it.",
|
|
891
|
+
409
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
const entry = entries.get(taskId);
|
|
895
|
+
if (entry?.working === true) {
|
|
896
|
+
throw new TaskRuntimeOperationError(
|
|
897
|
+
"runtime-busy",
|
|
898
|
+
"Abort the active Pi turn before restarting the runtime.",
|
|
899
|
+
409
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
const restart = (async () => {
|
|
903
|
+
let clean;
|
|
904
|
+
if (entry !== void 0) {
|
|
905
|
+
removeEntry(entry);
|
|
906
|
+
clean = await trackRuntimeCleanup(entry);
|
|
907
|
+
} else {
|
|
908
|
+
clean = await waitForTaskCleanups(taskId);
|
|
909
|
+
}
|
|
910
|
+
requireCleanupProven(taskId, clean, entry);
|
|
911
|
+
return startRuntime(taskId);
|
|
912
|
+
})().finally(() => {
|
|
913
|
+
if (startsInFlight.get(taskId) === restart) {
|
|
914
|
+
startsInFlight.delete(taskId);
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
startsInFlight.set(taskId, restart);
|
|
918
|
+
return restart;
|
|
919
|
+
},
|
|
920
|
+
setModel(taskId, provider, modelId) {
|
|
921
|
+
return trackRuntimeRequest(taskId, async () => {
|
|
922
|
+
requireNotDisposed();
|
|
923
|
+
const entry = requireModelRuntime(taskId);
|
|
924
|
+
if (entry.working) {
|
|
925
|
+
throw new TaskRuntimeOperationError(
|
|
926
|
+
"runtime-busy",
|
|
927
|
+
"Wait for Pi to finish before changing the model.",
|
|
928
|
+
409
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
try {
|
|
932
|
+
const selected = await entry.runtime.setModel(provider, modelId);
|
|
933
|
+
if (selected.provider !== provider || selected.id !== modelId) {
|
|
934
|
+
throw new Error("Pi selected a different model than requested.");
|
|
935
|
+
}
|
|
936
|
+
entry.model = normalizeModel(selected);
|
|
937
|
+
const authenticated = options.isProviderAuthenticated === void 0 ? true : await options.isProviderAuthenticated(provider);
|
|
938
|
+
entry.state = authenticated ? "ready" : "needs_auth";
|
|
939
|
+
entry.detail = authenticated ? `Using ${entry.model?.name ?? modelId} via ${provider}.` : `Sign in to ${provider} before sending a prompt.`;
|
|
940
|
+
publishStatus(entry);
|
|
941
|
+
return statusOf(entry);
|
|
942
|
+
} catch (error) {
|
|
943
|
+
const authRequired = isAuthRequiredError(error);
|
|
944
|
+
if (authRequired) {
|
|
945
|
+
entry.state = "needs_auth";
|
|
946
|
+
entry.detail = `Sign in to ${provider}, then select the model again.`;
|
|
947
|
+
publishStatus(entry);
|
|
948
|
+
}
|
|
949
|
+
throw new TaskRuntimeOperationError(
|
|
950
|
+
authRequired ? "runtime-auth-required" : "runtime-request-failed",
|
|
951
|
+
`Pi could not select ${provider}/${modelId}: ${errorMessage(error)}`,
|
|
952
|
+
authRequired ? 409 : 502,
|
|
953
|
+
{ cause: error }
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
},
|
|
958
|
+
async resolveApproval(taskId, approvalId, decision) {
|
|
959
|
+
requireNotDisposed();
|
|
960
|
+
assertTaskNotArchiving(taskId);
|
|
961
|
+
const entry = entries.get(taskId);
|
|
962
|
+
const pending = entry?.pendingApproval ?? null;
|
|
963
|
+
if (entry === void 0 || pending === null) {
|
|
964
|
+
throw new TaskRuntimeOperationError(
|
|
965
|
+
"approval-not-pending",
|
|
966
|
+
"This task has no approval waiting for a decision.",
|
|
967
|
+
409
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
if (pending.approval.approvalId !== approvalId) {
|
|
971
|
+
throw new TaskRuntimeOperationError(
|
|
972
|
+
"approval-not-pending",
|
|
973
|
+
"That approval is stale or belongs to a different runtime request.",
|
|
974
|
+
409
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
if (now() >= pending.approval.expiresAtMs) {
|
|
978
|
+
expirePendingApproval(entry, pending);
|
|
979
|
+
throw new TaskRuntimeOperationError(
|
|
980
|
+
"approval-not-pending",
|
|
981
|
+
"That approval expired; the action remains blocked.",
|
|
982
|
+
409
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
detachPendingApproval(entry);
|
|
986
|
+
try {
|
|
987
|
+
entry.runtime.respondToDialog({
|
|
988
|
+
requestId: pending.requestId,
|
|
989
|
+
kind: "confirm",
|
|
990
|
+
confirmed: decision === "approve"
|
|
991
|
+
});
|
|
992
|
+
} catch (error) {
|
|
993
|
+
publishApprovalResolved(entry, pending, "superseded");
|
|
994
|
+
options.publish(
|
|
995
|
+
entry.emit("log", {
|
|
996
|
+
level: "error",
|
|
997
|
+
message: `Failed to deliver approval decision ${approvalId}: ${errorMessage(error)}`
|
|
998
|
+
})
|
|
999
|
+
);
|
|
1000
|
+
throw new TaskRuntimeOperationError(
|
|
1001
|
+
"runtime-request-failed",
|
|
1002
|
+
"Pi could not accept the approval decision; the action remains blocked.",
|
|
1003
|
+
502,
|
|
1004
|
+
{ cause: error }
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
publishApprovalResolved(entry, pending, decision === "approve" ? "approved" : "denied");
|
|
1008
|
+
return statusOf(entry);
|
|
1009
|
+
},
|
|
1010
|
+
abort(taskId) {
|
|
1011
|
+
return trackRuntimeRequest(taskId, async () => {
|
|
1012
|
+
requireNotDisposed();
|
|
1013
|
+
const entry = requireLiveRuntime(taskId);
|
|
1014
|
+
if (exclusiveRuntimeRequests.has(taskId) && !entry.working && entry.pendingApproval === null) {
|
|
1015
|
+
throw new TaskRuntimeOperationError(
|
|
1016
|
+
"runtime-busy",
|
|
1017
|
+
"Another Pi runtime request is already in progress for this task.",
|
|
1018
|
+
409
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
supersedePendingApproval(entry, true);
|
|
1022
|
+
try {
|
|
1023
|
+
await entry.runtime.abort();
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
throw new TaskRuntimeOperationError(
|
|
1026
|
+
"runtime-request-failed",
|
|
1027
|
+
`Pi did not accept the abort: ${errorMessage(error)}`,
|
|
1028
|
+
502,
|
|
1029
|
+
{ cause: error }
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
return statusOf(entry);
|
|
1033
|
+
}, false);
|
|
1034
|
+
},
|
|
1035
|
+
isMutating(taskId) {
|
|
1036
|
+
const entry = entries.get(taskId);
|
|
1037
|
+
return startsInFlight.has(taskId) || activeRuntimeRequests.has(taskId) || archiveStops.has(taskId) || entry?.working === true || entry?.pendingApproval != null;
|
|
1038
|
+
},
|
|
1039
|
+
async stopForArchive(taskId) {
|
|
1040
|
+
requireNotDisposed();
|
|
1041
|
+
if (archiveStops.has(taskId)) {
|
|
1042
|
+
throw new TaskRuntimeOperationError(
|
|
1043
|
+
"runtime-busy",
|
|
1044
|
+
"Runtime cleanup is already active for this task.",
|
|
1045
|
+
409
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
const entry = entries.get(taskId);
|
|
1049
|
+
if (startsInFlight.has(taskId) || activeRuntimeRequests.has(taskId) || entry?.working === true || entry?.pendingApproval != null) {
|
|
1050
|
+
throw new TaskRuntimeOperationError(
|
|
1051
|
+
"runtime-busy",
|
|
1052
|
+
"Wait for Pi startup, the active turn, or its pending approval to settle before archiving.",
|
|
1053
|
+
409
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
archiveStops.add(taskId);
|
|
1057
|
+
try {
|
|
1058
|
+
let clean = await waitForTaskCleanups(taskId);
|
|
1059
|
+
if (entry !== void 0 && !entry.removed) {
|
|
1060
|
+
removeEntry(entry);
|
|
1061
|
+
clean = await trackRuntimeCleanup(entry) && clean;
|
|
1062
|
+
}
|
|
1063
|
+
clean = await waitForTaskCleanups(taskId) && clean;
|
|
1064
|
+
if (options.reconcileRuntimeProcess !== void 0) {
|
|
1065
|
+
try {
|
|
1066
|
+
await options.reconcileRuntimeProcess(taskId);
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
throw new TaskRuntimeOperationError(
|
|
1069
|
+
"runtime-recovery-required",
|
|
1070
|
+
`Webdesk could not safely reconcile the previous Pi runtime: ${errorMessage(error)}`,
|
|
1071
|
+
409,
|
|
1072
|
+
{ cause: error }
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
if (!clean) {
|
|
1077
|
+
publishCleanupUncertain(taskId, entry);
|
|
1078
|
+
throw new TaskRuntimeOperationError(
|
|
1079
|
+
"runtime-recovery-required",
|
|
1080
|
+
"Pi runtime cleanup could not be proven; Webdesk kept its recovery lease and did not archive the task.",
|
|
1081
|
+
409
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
lastStatuses.delete(taskId);
|
|
1085
|
+
} finally {
|
|
1086
|
+
archiveStops.delete(taskId);
|
|
1087
|
+
}
|
|
1088
|
+
},
|
|
1089
|
+
async disposeAll() {
|
|
1090
|
+
if (disposed) return;
|
|
1091
|
+
disposed = true;
|
|
1092
|
+
const live = [...entries.values()];
|
|
1093
|
+
entries.clear();
|
|
1094
|
+
await Promise.all([
|
|
1095
|
+
...runtimeCleanups.values(),
|
|
1096
|
+
...live.map(async (entry) => {
|
|
1097
|
+
supersedePendingApproval(entry, true);
|
|
1098
|
+
entry.removed = true;
|
|
1099
|
+
entry.unsubscribe();
|
|
1100
|
+
await trackRuntimeCleanup(entry);
|
|
1101
|
+
})
|
|
1102
|
+
]);
|
|
1103
|
+
runtimeCleanups.clear();
|
|
1104
|
+
uncertainRuntimeCleanups.clear();
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
function normalizeModel(model) {
|
|
1109
|
+
if (model === null || model.provider === void 0) return null;
|
|
1110
|
+
return {
|
|
1111
|
+
provider: model.provider,
|
|
1112
|
+
id: model.id,
|
|
1113
|
+
name: model.name ?? model.id
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
function isAuthRequiredError(error) {
|
|
1117
|
+
if (!isPiBridgeError(error, "PI_REQUEST_FAILED")) return false;
|
|
1118
|
+
const detail = error.details["error"];
|
|
1119
|
+
return typeof detail === "string" && /(?:no api key found|use \/login|not authenticated|authentication required)/i.test(detail);
|
|
1120
|
+
}
|
|
1121
|
+
export {
|
|
1122
|
+
TaskRuntimeOperationError,
|
|
1123
|
+
createTaskRuntimeCoordinator
|
|
1124
|
+
};
|