@osolmaz/pi-workflows 0.15.2 → 0.15.3
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/controllers/sqlite.d.ts +21 -0
- package/dist/controllers/sqlite.js +82 -1
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/extension/index.js +137 -4
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/session-delivery.d.ts +6 -0
- package/dist/extension/session-delivery.js +80 -25
- package/dist/extension/session-delivery.js.map +1 -1
- package/dist/extension/session-view.d.ts +16 -0
- package/dist/extension/session-view.js +119 -0
- package/dist/extension/session-view.js.map +1 -0
- package/dist/extension/widget.js +2 -1
- package/dist/extension/widget.js.map +1 -1
- package/dist/host/runner.d.ts +1 -0
- package/dist/host/runner.js +92 -26
- package/dist/host/runner.js.map +1 -1
- package/dist/host/state.d.ts +2 -0
- package/dist/host/state.js +7 -1
- package/dist/host/state.js.map +1 -1
- package/docs/2026-09-01-restore-session-delivery-controls-plan.md +139 -0
- package/docs/WORKFLOW_HOST.md +8 -4
- package/docs/WORKFLOW_STEP_MESSAGES.md +4 -4
- package/docs/workflows.md +12 -2
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/controllers/sqlite.ts +129 -1
- package/src/extension/index.ts +174 -4
- package/src/extension/session-delivery.ts +88 -25
- package/src/extension/session-view.ts +131 -0
- package/src/extension/widget.ts +3 -2
- package/src/host/runner.ts +103 -29
- package/src/host/state.ts +11 -1
package/src/extension/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { createRunId, WorkflowRunStore } from "../workflows/store.js";
|
|
|
14
14
|
import type { AgentStepContract, HumanDecisionResponse } from "../workflows/types.js";
|
|
15
15
|
import { parseControllerArgs, type ParsedControllerArgs } from "./controller-command.js";
|
|
16
16
|
import { SessionDeliveryCoordinator, type ClaimedSessionDelivery } from "./session-delivery.js";
|
|
17
|
+
import { SessionWorkflowView } from "./session-view.js";
|
|
17
18
|
import {
|
|
18
19
|
recoverAssistantStep,
|
|
19
20
|
registerWorkflowAgentStepMessageRenderer,
|
|
@@ -133,6 +134,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
133
134
|
let presentationTail = Promise.resolve();
|
|
134
135
|
let toolTail = Promise.resolve();
|
|
135
136
|
const sessionDelivery = new SessionDeliveryCoordinator();
|
|
137
|
+
const sessionView = new SessionWorkflowView();
|
|
136
138
|
|
|
137
139
|
const presentInOrder = async (ctx: ExtensionContext): Promise<void> => {
|
|
138
140
|
const prior = presentationTail;
|
|
@@ -148,6 +150,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
148
150
|
() => claimPendingTurnDelivery(pi, client, ctx),
|
|
149
151
|
]);
|
|
150
152
|
} finally {
|
|
153
|
+
sessionView.refresh(ctx);
|
|
151
154
|
release?.();
|
|
152
155
|
}
|
|
153
156
|
};
|
|
@@ -268,6 +271,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
268
271
|
},
|
|
269
272
|
});
|
|
270
273
|
|
|
274
|
+
pi.registerShortcut("shift+up", {
|
|
275
|
+
description: "Scroll the workflow widget up",
|
|
276
|
+
handler: (ctx) => sessionView.scrollUp(ctx),
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
pi.registerShortcut("shift+down", {
|
|
280
|
+
description: "Scroll the workflow widget down",
|
|
281
|
+
handler: (ctx) => sessionView.scrollDown(ctx),
|
|
282
|
+
});
|
|
283
|
+
|
|
271
284
|
pi.on("session_start", async (_event, ctx) => {
|
|
272
285
|
sessionContext = ctx;
|
|
273
286
|
try {
|
|
@@ -282,6 +295,44 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
282
295
|
pollTimer.unref?.();
|
|
283
296
|
});
|
|
284
297
|
|
|
298
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
299
|
+
const interrupted =
|
|
300
|
+
ctx.signal?.aborted === true ||
|
|
301
|
+
event.messages.some(
|
|
302
|
+
(message) =>
|
|
303
|
+
isRecord(message) && "stopReason" in message && message.stopReason === "aborted",
|
|
304
|
+
);
|
|
305
|
+
if (!interrupted) return;
|
|
306
|
+
const interaction = pendingInteractionForSession(ctx.sessionManager.getSessionId());
|
|
307
|
+
if (
|
|
308
|
+
interaction === undefined ||
|
|
309
|
+
interaction.kind === "decision" ||
|
|
310
|
+
workflowRunPaused(interaction.runId)
|
|
311
|
+
) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const turnHasPrompt = event.messages.some(
|
|
315
|
+
(message) => interactionRequestId(message) === interaction.requestId,
|
|
316
|
+
);
|
|
317
|
+
if (!turnHasPrompt) return;
|
|
318
|
+
try {
|
|
319
|
+
const pauseId = `escape-pause-${interaction.runId}-${randomUUID()}`;
|
|
320
|
+
await requestAccepted(client, {
|
|
321
|
+
operation: "run.pause",
|
|
322
|
+
requestId: pauseId,
|
|
323
|
+
idempotencyKey: pauseId,
|
|
324
|
+
runId: interaction.runId,
|
|
325
|
+
});
|
|
326
|
+
sessionView.refresh(ctx);
|
|
327
|
+
ctx.ui.notify(
|
|
328
|
+
`Workflow ${interaction.runId} paused because its model turn was interrupted. Use /workflow resume to continue.`,
|
|
329
|
+
"info",
|
|
330
|
+
);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
ctx.ui.notify(`Could not pause interrupted workflow: ${errorMessage(error)}`, "warning");
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
285
336
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
286
337
|
try {
|
|
287
338
|
await submitVisibleAssistantResponse(client, ctx);
|
|
@@ -291,9 +342,10 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
291
342
|
await presentInOrder(ctx).catch(() => undefined);
|
|
292
343
|
});
|
|
293
344
|
|
|
294
|
-
pi.on("session_shutdown", async () => {
|
|
345
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
295
346
|
sessionContext = null;
|
|
296
347
|
sessionDelivery.clear();
|
|
348
|
+
sessionView.clear(ctx);
|
|
297
349
|
if (pollTimer !== null) clearInterval(pollTimer);
|
|
298
350
|
pollTimer = null;
|
|
299
351
|
});
|
|
@@ -530,8 +582,17 @@ async function claimPendingInteractionDelivery(
|
|
|
530
582
|
entryIdentifier(entries.find((entry) => interactionRequestId(entry) === interaction.requestId));
|
|
531
583
|
const existingEntryId = findSessionEntryId(ctx.sessionManager.getBranch());
|
|
532
584
|
let presentationRevision = interaction.revision;
|
|
585
|
+
let claimExpiresAt = Number.POSITIVE_INFINITY;
|
|
533
586
|
if (existingEntryId === undefined) {
|
|
534
|
-
|
|
587
|
+
if (workflowRunPaused(interaction.runId)) return undefined;
|
|
588
|
+
if (
|
|
589
|
+
interaction.presentationClaimExpiresAt !== null &&
|
|
590
|
+
Date.parse(interaction.presentationClaimExpiresAt) > Date.now()
|
|
591
|
+
) {
|
|
592
|
+
return undefined;
|
|
593
|
+
}
|
|
594
|
+
await client.ensureRunning();
|
|
595
|
+
const claim = await client.request({
|
|
535
596
|
operation: "interaction.update",
|
|
536
597
|
requestId: `claim-presentation-${interaction.requestId}-${interaction.revision}-${client.clientId}`,
|
|
537
598
|
idempotencyKey: `claim-presentation-${interaction.requestId}-${interaction.revision}-${client.clientId}`,
|
|
@@ -539,13 +600,29 @@ async function claimPendingInteractionDelivery(
|
|
|
539
600
|
expectedRevision: interaction.revision,
|
|
540
601
|
payload: { requestId: interaction.requestId, claimPresentation: true },
|
|
541
602
|
});
|
|
603
|
+
if (claim.outcome === "conflict") return undefined;
|
|
604
|
+
if (claim.outcome !== "accepted" && claim.outcome !== "adopted") {
|
|
605
|
+
throw new Error(claim.error ?? "Workflow host rejected interaction.update");
|
|
606
|
+
}
|
|
542
607
|
if (claim.revision === undefined) throw new Error("Presentation claim has no revision");
|
|
608
|
+
const receipt = isRecord(claim.receipt) ? claim.receipt : undefined;
|
|
543
609
|
presentationRevision = claim.revision;
|
|
610
|
+
claimExpiresAt = claimExpiry(receipt?.presentationClaimExpiresAt, "presentation claim");
|
|
544
611
|
}
|
|
545
612
|
|
|
546
613
|
const contract = interactionContract(interaction);
|
|
547
614
|
return {
|
|
548
615
|
deliveryId: `interaction:${interaction.requestId}`,
|
|
616
|
+
claimExpiresAt,
|
|
617
|
+
isStillDeliverable: () =>
|
|
618
|
+
existingEntryId === undefined &&
|
|
619
|
+
interactionPresentationClaimIsLive({
|
|
620
|
+
requestId: interaction.requestId,
|
|
621
|
+
runId: interaction.runId,
|
|
622
|
+
presenterId: client.clientId,
|
|
623
|
+
revision: presentationRevision,
|
|
624
|
+
claimExpiresAt,
|
|
625
|
+
}),
|
|
549
626
|
findSessionEntryId,
|
|
550
627
|
send: () => {
|
|
551
628
|
if (interaction.kind === "decision") {
|
|
@@ -617,9 +694,18 @@ async function claimPendingNotificationDelivery(
|
|
|
617
694
|
const notification = isRecord(receipt?.notification) ? receipt.notification : undefined;
|
|
618
695
|
if (notification === undefined) return undefined;
|
|
619
696
|
const claimId = requireText(receipt?.claimId, "notification claimId");
|
|
697
|
+
const claimExpiresAt = claimExpiry(receipt?.claimExpiresAt, "notification claim");
|
|
620
698
|
const notificationId = requireText(notification.notificationId, "notificationId");
|
|
621
699
|
return {
|
|
622
700
|
deliveryId: `notification:${notificationId}`,
|
|
701
|
+
claimExpiresAt,
|
|
702
|
+
isStillDeliverable: () =>
|
|
703
|
+
hostDeliveryClaimIsLive(client, {
|
|
704
|
+
kind: "notification",
|
|
705
|
+
resourceId: notificationId,
|
|
706
|
+
targetSessionId: sessionId,
|
|
707
|
+
claimId,
|
|
708
|
+
}),
|
|
623
709
|
findSessionEntryId: (entries) =>
|
|
624
710
|
entryIdentifier(
|
|
625
711
|
entries.find(
|
|
@@ -678,12 +764,21 @@ async function claimPendingTurnDelivery(
|
|
|
678
764
|
const turn = isRecord(receipt?.turn) ? receipt.turn : undefined;
|
|
679
765
|
if (turn === undefined) return undefined;
|
|
680
766
|
const claimId = requireText(receipt?.claimId, "turn claimId");
|
|
767
|
+
const claimExpiresAt = claimExpiry(receipt?.claimExpiresAt, "turn claim");
|
|
681
768
|
const intentId = requireText(turn.intentId, "turn intentId");
|
|
682
769
|
const runId = requireText(turn.runId, "turn runId");
|
|
683
770
|
const state = terminalRunState(runId);
|
|
684
771
|
if (state === undefined) return undefined;
|
|
685
772
|
return {
|
|
686
773
|
deliveryId: `turn:${intentId}`,
|
|
774
|
+
claimExpiresAt,
|
|
775
|
+
isStillDeliverable: () =>
|
|
776
|
+
hostDeliveryClaimIsLive(client, {
|
|
777
|
+
kind: "turn",
|
|
778
|
+
resourceId: intentId,
|
|
779
|
+
targetSessionId: sessionId,
|
|
780
|
+
claimId,
|
|
781
|
+
}),
|
|
687
782
|
findSessionEntryId: (entries) =>
|
|
688
783
|
entryIdentifier(
|
|
689
784
|
entries.find(
|
|
@@ -723,7 +818,13 @@ async function submitVisibleAssistantResponse(
|
|
|
723
818
|
ctx: ExtensionContext,
|
|
724
819
|
): Promise<void> {
|
|
725
820
|
const interaction = pendingInteractionForSession(ctx.sessionManager.getSessionId());
|
|
726
|
-
if (
|
|
821
|
+
if (
|
|
822
|
+
interaction === undefined ||
|
|
823
|
+
interaction.kind !== "assistant" ||
|
|
824
|
+
workflowRunPaused(interaction.runId)
|
|
825
|
+
) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
727
828
|
const contract = agentContract(interaction);
|
|
728
829
|
if (contract === undefined) return;
|
|
729
830
|
const submission = recoverAssistantStep(ctx.sessionManager.getBranch(), contract);
|
|
@@ -874,6 +975,65 @@ function terminalRunState(runId: string): Record<string, unknown> | undefined {
|
|
|
874
975
|
}
|
|
875
976
|
}
|
|
876
977
|
|
|
978
|
+
function workflowRunPaused(runId: string): boolean {
|
|
979
|
+
return terminalRunState(runId)?.paused === true;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function interactionPresentationClaimIsLive(options: {
|
|
983
|
+
requestId: string;
|
|
984
|
+
runId: string;
|
|
985
|
+
presenterId: string;
|
|
986
|
+
revision: number;
|
|
987
|
+
claimExpiresAt: number;
|
|
988
|
+
}): boolean {
|
|
989
|
+
try {
|
|
990
|
+
const store = new HostStateStore(workflowStatePath(), { readOnly: true });
|
|
991
|
+
try {
|
|
992
|
+
const interaction = store.getInteraction(options.requestId);
|
|
993
|
+
return (
|
|
994
|
+
interaction?.runId === options.runId &&
|
|
995
|
+
interaction.status === "presenting" &&
|
|
996
|
+
interaction.presenterId === options.presenterId &&
|
|
997
|
+
interaction.revision === options.revision &&
|
|
998
|
+
interaction.presentationSessionEntryId === null &&
|
|
999
|
+
interaction.presentationClaimExpiresAt !== null &&
|
|
1000
|
+
Date.parse(interaction.presentationClaimExpiresAt) === options.claimExpiresAt &&
|
|
1001
|
+
!workflowRunPaused(options.runId)
|
|
1002
|
+
);
|
|
1003
|
+
} finally {
|
|
1004
|
+
store.close();
|
|
1005
|
+
}
|
|
1006
|
+
} catch {
|
|
1007
|
+
return false;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async function hostDeliveryClaimIsLive(
|
|
1012
|
+
client: WorkflowHostClient,
|
|
1013
|
+
options: {
|
|
1014
|
+
kind: "notification" | "turn";
|
|
1015
|
+
resourceId: string;
|
|
1016
|
+
targetSessionId: string;
|
|
1017
|
+
claimId: string;
|
|
1018
|
+
},
|
|
1019
|
+
): Promise<boolean> {
|
|
1020
|
+
try {
|
|
1021
|
+
const validationId = randomUUID();
|
|
1022
|
+
const response = await client.request({
|
|
1023
|
+
operation: options.kind === "notification" ? "notification.claim" : "turn.claim",
|
|
1024
|
+
requestId: `delivery-validate-${options.claimId}-${validationId}`,
|
|
1025
|
+
idempotencyKey: validationId,
|
|
1026
|
+
payload: { ...options, validateClaim: true },
|
|
1027
|
+
});
|
|
1028
|
+
const receipt = isRecord(response.receipt) ? response.receipt : undefined;
|
|
1029
|
+
return (
|
|
1030
|
+
(response.outcome === "accepted" || response.outcome === "adopted") && receipt?.live === true
|
|
1031
|
+
);
|
|
1032
|
+
} catch {
|
|
1033
|
+
return false;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
877
1037
|
function presentationMessage(
|
|
878
1038
|
turn: Record<string, unknown>,
|
|
879
1039
|
state: Record<string, unknown>,
|
|
@@ -929,6 +1089,12 @@ function requireText(value: unknown, name: string): string {
|
|
|
929
1089
|
return value;
|
|
930
1090
|
}
|
|
931
1091
|
|
|
1092
|
+
function claimExpiry(value: unknown, name: string): number {
|
|
1093
|
+
const expiry = Date.parse(requireText(value, `${name} expiry`));
|
|
1094
|
+
if (!Number.isFinite(expiry)) throw new Error(`${name} expiry must be a timestamp`);
|
|
1095
|
+
return expiry;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
932
1098
|
function activeSessionRun(ctx: ExtensionContext): WorkflowRunQueueRecord | undefined {
|
|
933
1099
|
return sessionRun(ctx);
|
|
934
1100
|
}
|
|
@@ -1023,7 +1189,11 @@ function agentContract(interaction: InteractiveRequestRecord): AgentStepContract
|
|
|
1023
1189
|
}
|
|
1024
1190
|
|
|
1025
1191
|
function interactionRequestId(value: unknown): string | undefined {
|
|
1026
|
-
if (
|
|
1192
|
+
if (
|
|
1193
|
+
!isRecord(value) ||
|
|
1194
|
+
(value.type !== "custom_message" && value.role !== "custom") ||
|
|
1195
|
+
!isRecord(value.details)
|
|
1196
|
+
) {
|
|
1027
1197
|
return undefined;
|
|
1028
1198
|
}
|
|
1029
1199
|
return typeof value.details.requestId === "string" ? value.details.requestId : undefined;
|
|
@@ -2,6 +2,8 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
|
|
3
3
|
export type ClaimedSessionDelivery = {
|
|
4
4
|
deliveryId: string;
|
|
5
|
+
claimExpiresAt: number;
|
|
6
|
+
isStillDeliverable: () => boolean | Promise<boolean>;
|
|
5
7
|
findSessionEntryId: (entries: readonly unknown[]) => string | undefined;
|
|
6
8
|
send: () => void;
|
|
7
9
|
settle: (sessionEntryId: string) => Promise<void>;
|
|
@@ -26,6 +28,7 @@ const SESSION_ENTRY_CONFIRMATION_MS = 10_000;
|
|
|
26
28
|
*/
|
|
27
29
|
export class SessionDeliveryCoordinator {
|
|
28
30
|
private readonly queued = new Map<string, QueuedSessionDelivery>();
|
|
31
|
+
private claimed: ClaimedSessionDelivery | undefined;
|
|
29
32
|
private synchronizing = false;
|
|
30
33
|
|
|
31
34
|
async synchronize(
|
|
@@ -36,6 +39,7 @@ export class SessionDeliveryCoordinator {
|
|
|
36
39
|
this.synchronizing = true;
|
|
37
40
|
try {
|
|
38
41
|
if (await this.settleQueued(ctx)) return;
|
|
42
|
+
if (await this.sendClaimed(ctx)) return;
|
|
39
43
|
if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
|
|
40
44
|
|
|
41
45
|
for (const claim of claimers) {
|
|
@@ -44,32 +48,17 @@ export class SessionDeliveryCoordinator {
|
|
|
44
48
|
|
|
45
49
|
const existingEntryId = delivery.findSessionEntryId(ctx.sessionManager.getBranch());
|
|
46
50
|
if (existingEntryId !== undefined) {
|
|
47
|
-
|
|
51
|
+
if (delivery.claimExpiresAt <= Date.now()) return;
|
|
52
|
+
this.rememberQueued(delivery);
|
|
53
|
+
await this.settleDelivery(ctx, delivery.deliveryId, existingEntryId);
|
|
48
54
|
return;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
//
|
|
52
|
-
// request is in flight,
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.queued.set(delivery.deliveryId, {
|
|
57
|
-
delivery,
|
|
58
|
-
queuedAt: Date.now(),
|
|
59
|
-
ambiguityReported: false,
|
|
60
|
-
});
|
|
61
|
-
try {
|
|
62
|
-
delivery.send();
|
|
63
|
-
} catch (error) {
|
|
64
|
-
this.queued.delete(delivery.deliveryId);
|
|
65
|
-
throw error;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const insertedEntryId = delivery.findSessionEntryId(ctx.sessionManager.getBranch());
|
|
69
|
-
if (insertedEntryId !== undefined) {
|
|
70
|
-
this.queued.delete(delivery.deliveryId);
|
|
71
|
-
await delivery.settle(insertedEntryId);
|
|
72
|
-
}
|
|
57
|
+
// Remember the host claim before the final idle check. If Pi starts a
|
|
58
|
+
// turn while the claim request is in flight, a later poll can use this
|
|
59
|
+
// exact still-live claim instead of making a conflicting second claim.
|
|
60
|
+
this.claimed = delivery;
|
|
61
|
+
await this.sendClaimed(ctx);
|
|
73
62
|
return;
|
|
74
63
|
}
|
|
75
64
|
} finally {
|
|
@@ -78,9 +67,63 @@ export class SessionDeliveryCoordinator {
|
|
|
78
67
|
}
|
|
79
68
|
|
|
80
69
|
clear(): void {
|
|
70
|
+
this.claimed = undefined;
|
|
81
71
|
this.queued.clear();
|
|
82
72
|
}
|
|
83
73
|
|
|
74
|
+
private async sendClaimed(
|
|
75
|
+
ctx: Pick<ExtensionContext, "hasPendingMessages" | "isIdle" | "sessionManager" | "ui">,
|
|
76
|
+
): Promise<boolean> {
|
|
77
|
+
const delivery = this.claimed;
|
|
78
|
+
if (delivery === undefined) return false;
|
|
79
|
+
if (delivery.claimExpiresAt <= Date.now()) {
|
|
80
|
+
this.claimed = undefined;
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const existingEntryId = delivery.findSessionEntryId(ctx.sessionManager.getBranch());
|
|
85
|
+
if (existingEntryId !== undefined) {
|
|
86
|
+
this.rememberQueued(delivery);
|
|
87
|
+
this.claimed = undefined;
|
|
88
|
+
await this.settleDelivery(ctx, delivery.deliveryId, existingEntryId);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages()) return true;
|
|
92
|
+
if (!(await delivery.isStillDeliverable())) {
|
|
93
|
+
this.claimed = undefined;
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
if (delivery.claimExpiresAt <= Date.now()) {
|
|
97
|
+
this.claimed = undefined;
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages()) return true;
|
|
101
|
+
|
|
102
|
+
this.rememberQueued(delivery);
|
|
103
|
+
this.claimed = undefined;
|
|
104
|
+
try {
|
|
105
|
+
delivery.send();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
this.queued.delete(delivery.deliveryId);
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const insertedEntryId = delivery.findSessionEntryId(ctx.sessionManager.getBranch());
|
|
112
|
+
if (insertedEntryId !== undefined) {
|
|
113
|
+
await this.settleDelivery(ctx, delivery.deliveryId, insertedEntryId);
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private rememberQueued(delivery: ClaimedSessionDelivery): void {
|
|
119
|
+
if (this.queued.has(delivery.deliveryId)) return;
|
|
120
|
+
this.queued.set(delivery.deliveryId, {
|
|
121
|
+
delivery,
|
|
122
|
+
queuedAt: Date.now(),
|
|
123
|
+
ambiguityReported: false,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
84
127
|
private async settleQueued(
|
|
85
128
|
ctx: Pick<ExtensionContext, "hasPendingMessages" | "isIdle" | "sessionManager" | "ui">,
|
|
86
129
|
): Promise<boolean> {
|
|
@@ -103,9 +146,29 @@ export class SessionDeliveryCoordinator {
|
|
|
103
146
|
}
|
|
104
147
|
return true;
|
|
105
148
|
}
|
|
106
|
-
this.
|
|
107
|
-
await queued.delivery.settle(sessionEntryId);
|
|
149
|
+
await this.settleDelivery(ctx, deliveryId, sessionEntryId);
|
|
108
150
|
}
|
|
109
151
|
return hadQueuedDelivery;
|
|
110
152
|
}
|
|
153
|
+
|
|
154
|
+
private async settleDelivery(
|
|
155
|
+
ctx: Pick<ExtensionContext, "ui">,
|
|
156
|
+
deliveryId: string,
|
|
157
|
+
sessionEntryId: string,
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
const queued = this.queued.get(deliveryId);
|
|
160
|
+
if (queued === undefined) return;
|
|
161
|
+
try {
|
|
162
|
+
await queued.delivery.settle(sessionEntryId);
|
|
163
|
+
this.queued.delete(deliveryId);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (!queued.ambiguityReported) {
|
|
166
|
+
queued.ambiguityReported = true;
|
|
167
|
+
ctx.ui.notify(
|
|
168
|
+
`Workflow session delivery ${deliveryId} is visible but its durable receipt is ambiguous: ${String(error)}. Do not retry it until recovery checks the session history.`,
|
|
169
|
+
"warning",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
111
174
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { SqliteControllerStore } from "../controllers/sqlite.js";
|
|
3
|
+
import { workflowStatePath } from "../state/database.js";
|
|
4
|
+
import { WorkflowRunStore, type LoadedWorkflowRun } from "../workflows/store.js";
|
|
5
|
+
import { buildWidgetView } from "./widget.js";
|
|
6
|
+
|
|
7
|
+
const WIDGET_KEY = "pi-workflows";
|
|
8
|
+
const WIDGET_SCROLL_STEP = 3;
|
|
9
|
+
|
|
10
|
+
/** Read-only projection of the host-owned run into the origin Pi session. */
|
|
11
|
+
export class SessionWorkflowView {
|
|
12
|
+
private loaded: LoadedWorkflowRun | undefined;
|
|
13
|
+
private scroll: number | null = null;
|
|
14
|
+
private shownScroll = 0;
|
|
15
|
+
private maxScroll = 0;
|
|
16
|
+
private stepCount = 0;
|
|
17
|
+
private visible = false;
|
|
18
|
+
|
|
19
|
+
refresh(ctx: ExtensionContext): void {
|
|
20
|
+
const loaded = loadSessionRun(ctx.sessionManager.getSessionId());
|
|
21
|
+
if (loaded === undefined) {
|
|
22
|
+
this.clear(ctx);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (this.loaded?.runId !== loaded.runId || this.stepCount !== loaded.state.steps.length) {
|
|
26
|
+
this.scroll = null;
|
|
27
|
+
this.stepCount = loaded.state.steps.length;
|
|
28
|
+
}
|
|
29
|
+
this.loaded = loaded;
|
|
30
|
+
this.render(ctx);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
scrollUp(ctx: ExtensionContext): void {
|
|
34
|
+
this.scrollBy(ctx, -WIDGET_SCROLL_STEP);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
scrollDown(ctx: ExtensionContext): void {
|
|
38
|
+
this.scrollBy(ctx, WIDGET_SCROLL_STEP);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
clear(ctx: ExtensionContext): void {
|
|
42
|
+
this.loaded = undefined;
|
|
43
|
+
this.scroll = null;
|
|
44
|
+
this.shownScroll = 0;
|
|
45
|
+
this.maxScroll = 0;
|
|
46
|
+
this.stepCount = 0;
|
|
47
|
+
if (!this.visible) return;
|
|
48
|
+
this.visible = false;
|
|
49
|
+
safelyUpdateUi(ctx, () => {
|
|
50
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
51
|
+
ctx.ui.setStatus(WIDGET_KEY, undefined);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private scrollBy(ctx: ExtensionContext, delta: number): void {
|
|
56
|
+
if (this.loaded === undefined) return;
|
|
57
|
+
const current = this.scroll ?? this.shownScroll;
|
|
58
|
+
this.scroll = Math.max(0, Math.min(this.maxScroll, current + delta));
|
|
59
|
+
this.render(ctx);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private render(ctx: ExtensionContext): void {
|
|
63
|
+
const loaded = this.loaded;
|
|
64
|
+
if (loaded === undefined) return;
|
|
65
|
+
const render = (
|
|
66
|
+
width = Number.POSITIVE_INFINITY,
|
|
67
|
+
theme?: Parameters<typeof buildWidgetView>[6],
|
|
68
|
+
) => {
|
|
69
|
+
const view = buildWidgetView(
|
|
70
|
+
loaded.state,
|
|
71
|
+
loaded.snapshot,
|
|
72
|
+
new Date(),
|
|
73
|
+
this.scroll,
|
|
74
|
+
loaded.state.paused === true,
|
|
75
|
+
width,
|
|
76
|
+
theme,
|
|
77
|
+
undefined,
|
|
78
|
+
);
|
|
79
|
+
this.shownScroll = view.scroll;
|
|
80
|
+
this.maxScroll = view.maxScroll;
|
|
81
|
+
if (this.scroll !== null) this.scroll = view.scroll;
|
|
82
|
+
return view.lines;
|
|
83
|
+
};
|
|
84
|
+
safelyUpdateUi(ctx, () => {
|
|
85
|
+
if (ctx.mode === "tui") {
|
|
86
|
+
ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) => ({
|
|
87
|
+
render: (width) => render(width, theme),
|
|
88
|
+
invalidate() {},
|
|
89
|
+
}));
|
|
90
|
+
} else {
|
|
91
|
+
ctx.ui.setWidget(WIDGET_KEY, render());
|
|
92
|
+
}
|
|
93
|
+
const label = loaded.state.paused === true ? "paused" : loaded.state.status;
|
|
94
|
+
ctx.ui.setStatus(WIDGET_KEY, `${loaded.state.workflowName} [${label}]`);
|
|
95
|
+
this.visible = true;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function loadSessionRun(sessionId: string): LoadedWorkflowRun | undefined {
|
|
101
|
+
try {
|
|
102
|
+
const queue = new SqliteControllerStore(workflowStatePath(), {
|
|
103
|
+
readOnly: true,
|
|
104
|
+
global: true,
|
|
105
|
+
});
|
|
106
|
+
let runId: string | undefined;
|
|
107
|
+
try {
|
|
108
|
+
runId = queue.findSessionReservation(sessionId)?.runId;
|
|
109
|
+
} finally {
|
|
110
|
+
queue.close();
|
|
111
|
+
}
|
|
112
|
+
if (runId === undefined) return undefined;
|
|
113
|
+
|
|
114
|
+
const runs = new WorkflowRunStore(workflowStatePath(), { readOnly: true });
|
|
115
|
+
try {
|
|
116
|
+
return runs.readRun(runId) ?? undefined;
|
|
117
|
+
} finally {
|
|
118
|
+
runs.close();
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function safelyUpdateUi(ctx: ExtensionContext, update: () => void): void {
|
|
126
|
+
try {
|
|
127
|
+
if (ctx.hasUI) update();
|
|
128
|
+
} catch {
|
|
129
|
+
// A session replacement can make a captured context stale between polls.
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/extension/widget.ts
CHANGED
|
@@ -86,7 +86,7 @@ export function buildWidgetView(
|
|
|
86
86
|
// `held` covers pauses the state cannot see yet: an escape-interrupted
|
|
87
87
|
// step or a pause requested while the current node is still finishing.
|
|
88
88
|
const paused = held || state.paused === true;
|
|
89
|
-
const glyph = paused ? "⏸" : STATUS_GLYPHS[state.status];
|
|
89
|
+
const glyph = paused ? "⏸" : (STATUS_GLYPHS[state.status] ?? "·");
|
|
90
90
|
const statusText = paused ? "paused" : state.status;
|
|
91
91
|
// Titles, status details, and errors can carry model- or shell-controlled
|
|
92
92
|
// text; never let escape sequences or newlines reach the terminal.
|
|
@@ -398,7 +398,7 @@ function elapsedSince(startedAt: string | undefined, now: Date): string | null {
|
|
|
398
398
|
return formatDuration(Math.max(0, now.getTime() - started));
|
|
399
399
|
}
|
|
400
400
|
|
|
401
|
-
function statusTone(status:
|
|
401
|
+
function statusTone(status: string): ThemeColor {
|
|
402
402
|
switch (status) {
|
|
403
403
|
case "completed":
|
|
404
404
|
return "success";
|
|
@@ -409,6 +409,7 @@ function statusTone(status: WorkflowRunStatus): ThemeColor {
|
|
|
409
409
|
case "waiting":
|
|
410
410
|
return "warning";
|
|
411
411
|
case "running":
|
|
412
|
+
default:
|
|
412
413
|
return "accent";
|
|
413
414
|
}
|
|
414
415
|
}
|