@bermudi/pi-delegate 0.1.19 → 0.1.20
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 +50 -2
- package/assistant-preview.ts +31 -0
- package/browser-state.ts +250 -0
- package/browser.ts +334 -0
- package/delegate.ts +2 -0
- package/dispatch.ts +73 -21
- package/extension.ts +30 -2
- package/format.ts +23 -8
- package/lifecycle.ts +3 -0
- package/manual.ts +10 -1
- package/package.json +1 -1
- package/pause.ts +81 -0
- package/render-branches.ts +17 -4
- package/render-result.ts +1 -0
- package/runner.ts +55 -1
- package/schema.ts +7 -5
- package/status.ts +14 -0
- package/ticket-format.ts +25 -4
- package/tickets.ts +54 -2
- package/types.ts +14 -0
package/dispatch.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
|
|
3
3
|
import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
|
|
4
|
+
import { PauseController } from "./pause.ts";
|
|
4
5
|
import {
|
|
5
6
|
getConcurrencyLimit,
|
|
6
7
|
getMaxAsyncTickets,
|
|
@@ -303,6 +304,7 @@ async function sharedWriteSafetyFailure(
|
|
|
303
304
|
* `finally` so throw, abort, and queued-abort paths all unblock successors. */
|
|
304
305
|
function buildSerializationGate(
|
|
305
306
|
groups: readonly SharedWriteConflict[] | undefined,
|
|
307
|
+
progress: TaskProgress[],
|
|
306
308
|
):
|
|
307
309
|
| {
|
|
308
310
|
beforeAcquire: (index: number) => Promise<void>;
|
|
@@ -314,6 +316,8 @@ function buildSerializationGate(
|
|
|
314
316
|
for (const { taskIndexes } of groups) {
|
|
315
317
|
for (let position = 1; position < taskIndexes.length; position++) {
|
|
316
318
|
predecessor.set(taskIndexes[position]!, taskIndexes[position - 1]!);
|
|
319
|
+
const row = progress[taskIndexes[position]!];
|
|
320
|
+
if (row) row.waitingFor = taskIndexes[position - 1]!;
|
|
317
321
|
}
|
|
318
322
|
}
|
|
319
323
|
if (predecessor.size === 0) return undefined;
|
|
@@ -328,6 +332,8 @@ function buildSerializationGate(
|
|
|
328
332
|
beforeAcquire: async (index) => {
|
|
329
333
|
const predecessorIndex = predecessor.get(index);
|
|
330
334
|
if (predecessorIndex !== undefined) await settled.get(predecessorIndex);
|
|
335
|
+
const row = progress[index];
|
|
336
|
+
if (row) row.waitingFor = undefined;
|
|
331
337
|
},
|
|
332
338
|
complete: (index) => resolvers.get(index)?.(),
|
|
333
339
|
};
|
|
@@ -824,6 +830,21 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
824
830
|
config: dispatchConfig,
|
|
825
831
|
};
|
|
826
832
|
runtime.tickets.set(ticketId, ticket);
|
|
833
|
+
let lastPauseState = "running";
|
|
834
|
+
const pause = new PauseController(() => {
|
|
835
|
+
if (pause.state !== lastPauseState) {
|
|
836
|
+
lastPauseState = pause.state;
|
|
837
|
+
if (ticket.status === "running")
|
|
838
|
+
console.info(`[delegate] ticket '${ticketId}' ${lastPauseState}`);
|
|
839
|
+
}
|
|
840
|
+
for (const p of ticket.progress) p.paused = pause.isParked(p.index);
|
|
841
|
+
runtime.tickets.notifyWaiters(ticket);
|
|
842
|
+
syncDelegateStatus(undefined, runtime);
|
|
843
|
+
});
|
|
844
|
+
ticket.pause = pause;
|
|
845
|
+
// Preparation/source integration are operations too: a pause request must
|
|
846
|
+
// not claim quiet while either is mutating a workspace.
|
|
847
|
+
pause.enter(-1);
|
|
827
848
|
callSpan?.spawn();
|
|
828
849
|
// Footer visibility for the new background work (see status.ts). Uses the
|
|
829
850
|
// ctx cached from the dispatch path in extension.ts — DelegateToolCtx is
|
|
@@ -836,6 +857,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
836
857
|
const modelRegistry = ctx.modelRegistry;
|
|
837
858
|
|
|
838
859
|
const asyncEnv: TaskRunEnv = {
|
|
860
|
+
pause,
|
|
839
861
|
signal: ticketSignal,
|
|
840
862
|
modelRegistry,
|
|
841
863
|
ticketId,
|
|
@@ -879,6 +901,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
879
901
|
let executionResolved = resolved;
|
|
880
902
|
let isolatedBatch: PreparedIsolatedBatch | undefined;
|
|
881
903
|
try {
|
|
904
|
+
await pause.checkpoint(-1, ticketSignal);
|
|
882
905
|
isolatedBatch = await prepareIsolatedBatch(resolved, ticketSignal);
|
|
883
906
|
if (isolatedBatch) executionResolved = isolatedBatch.resolved;
|
|
884
907
|
} catch (error) {
|
|
@@ -887,31 +910,44 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
887
910
|
`Isolated workspace setup failed; no subagents were started. ${detail}`,
|
|
888
911
|
{ cause: error },
|
|
889
912
|
);
|
|
913
|
+
} finally {
|
|
914
|
+
pause.leave(-1);
|
|
890
915
|
}
|
|
891
916
|
|
|
892
917
|
let results: TaskResult[];
|
|
893
|
-
const gate = buildSerializationGate(serializedGroups);
|
|
918
|
+
const gate = buildSerializationGate(serializedGroups, ticket.progress);
|
|
894
919
|
try {
|
|
895
920
|
results = await mapConcurrentByModel(
|
|
896
921
|
executionResolved,
|
|
897
922
|
(t) => getModelKey(t.model),
|
|
898
923
|
(modelKey) => getConcurrencyLimit(modelKey, dispatchConfig),
|
|
899
924
|
async (t, i) => {
|
|
925
|
+
pause.enter(i);
|
|
900
926
|
try {
|
|
927
|
+
await pause.checkpoint(i, ticketSignal);
|
|
901
928
|
const result = await runResolvedTask(
|
|
902
929
|
asyncEnv,
|
|
903
930
|
t,
|
|
904
931
|
ticket.progress[i]!,
|
|
905
932
|
i,
|
|
906
933
|
);
|
|
934
|
+
const group = serializedGroups?.findIndex((group) =>
|
|
935
|
+
group.taskIndexes.includes(i),
|
|
936
|
+
);
|
|
937
|
+
if (group !== undefined && group >= 0)
|
|
938
|
+
result.serializedGroup = group;
|
|
907
939
|
ticket.results[i] = result;
|
|
908
940
|
return result;
|
|
909
941
|
} finally {
|
|
942
|
+
pause.leave(i);
|
|
910
943
|
gate?.complete(i);
|
|
911
944
|
}
|
|
912
945
|
},
|
|
913
946
|
ticketSignal,
|
|
914
|
-
|
|
947
|
+
async (i) => {
|
|
948
|
+
await gate?.beforeAcquire(i);
|
|
949
|
+
await pause.checkpoint(i, ticketSignal);
|
|
950
|
+
},
|
|
915
951
|
);
|
|
916
952
|
} catch (error) {
|
|
917
953
|
results = completeUnexpectedResults(
|
|
@@ -921,34 +957,46 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
921
957
|
error,
|
|
922
958
|
);
|
|
923
959
|
if (isolatedBatch) {
|
|
960
|
+
pause.enter(-1);
|
|
961
|
+
try {
|
|
962
|
+
await pause.checkpoint(-1, ticketSignal);
|
|
963
|
+
results = await reconcileIsolatedResults(
|
|
964
|
+
isolatedBatch,
|
|
965
|
+
resolved,
|
|
966
|
+
results,
|
|
967
|
+
{
|
|
968
|
+
shouldApplySource: () => false,
|
|
969
|
+
retainedReason:
|
|
970
|
+
"Batch execution failed before source application; completed proposals were retained for recovery.",
|
|
971
|
+
},
|
|
972
|
+
);
|
|
973
|
+
} finally {
|
|
974
|
+
pause.leave(-1);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
ticket.results = [...results];
|
|
978
|
+
throw error;
|
|
979
|
+
}
|
|
980
|
+
if (isolatedBatch) {
|
|
981
|
+
pause.enter(-1);
|
|
982
|
+
try {
|
|
983
|
+
await pause.checkpoint(-1, ticketSignal);
|
|
924
984
|
results = await reconcileIsolatedResults(
|
|
925
985
|
isolatedBatch,
|
|
926
986
|
resolved,
|
|
927
987
|
results,
|
|
928
988
|
{
|
|
929
|
-
shouldApplySource: () =>
|
|
989
|
+
shouldApplySource: () =>
|
|
990
|
+
ticket.status === "running" && !ticketSignal.aborted,
|
|
991
|
+
signal: ticketSignal,
|
|
930
992
|
retainedReason:
|
|
931
|
-
"
|
|
993
|
+
"The async ticket was cancelled before source application; the proposal was retained for recovery.",
|
|
932
994
|
},
|
|
933
995
|
);
|
|
996
|
+
} finally {
|
|
997
|
+
pause.leave(-1);
|
|
934
998
|
}
|
|
935
999
|
ticket.results = [...results];
|
|
936
|
-
throw error;
|
|
937
|
-
}
|
|
938
|
-
if (isolatedBatch) {
|
|
939
|
-
results = await reconcileIsolatedResults(
|
|
940
|
-
isolatedBatch,
|
|
941
|
-
resolved,
|
|
942
|
-
results,
|
|
943
|
-
{
|
|
944
|
-
shouldApplySource: () =>
|
|
945
|
-
ticket.status === "running" && !ticketSignal.aborted,
|
|
946
|
-
signal: ticketSignal,
|
|
947
|
-
retainedReason:
|
|
948
|
-
"The async ticket was cancelled before source application; the proposal was retained for recovery.",
|
|
949
|
-
},
|
|
950
|
-
);
|
|
951
|
-
ticket.results = [...results];
|
|
952
1000
|
}
|
|
953
1001
|
|
|
954
1002
|
// Worker execution and isolated reconciliation are complete. Publish that
|
|
@@ -1100,7 +1148,7 @@ export async function dispatchSync(
|
|
|
1100
1148
|
);
|
|
1101
1149
|
let results: TaskResult[];
|
|
1102
1150
|
let isolatedReconciled = false;
|
|
1103
|
-
const gate = buildSerializationGate(serializedGroups);
|
|
1151
|
+
const gate = buildSerializationGate(serializedGroups, progress);
|
|
1104
1152
|
try {
|
|
1105
1153
|
results = await mapConcurrentByModel(
|
|
1106
1154
|
executionResolved,
|
|
@@ -1109,6 +1157,10 @@ export async function dispatchSync(
|
|
|
1109
1157
|
async (t, i) => {
|
|
1110
1158
|
try {
|
|
1111
1159
|
const result = await runResolvedTask(syncEnv, t, progress[i]!, i);
|
|
1160
|
+
const group = serializedGroups?.findIndex((group) =>
|
|
1161
|
+
group.taskIndexes.includes(i),
|
|
1162
|
+
);
|
|
1163
|
+
if (group !== undefined && group >= 0) result.serializedGroup = group;
|
|
1112
1164
|
partialResults[i] = result;
|
|
1113
1165
|
return result;
|
|
1114
1166
|
} finally {
|
package/extension.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { registerSubagentBrowser } from "./browser.ts";
|
|
2
3
|
import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
|
|
3
4
|
import { discoverAgents } from "./agents.ts";
|
|
4
5
|
import { getSubagentManualMarkdown } from "./manual.ts";
|
|
@@ -136,6 +137,7 @@ export default function delegateExtension(
|
|
|
136
137
|
// its SQLite handle. Permit the new runtime to open a fresh backend; stale
|
|
137
138
|
// workers from the old runtime remain blocked from reopening it.
|
|
138
139
|
prepareTelemetryForSession();
|
|
140
|
+
const browserHistory = registerSubagentBrowser(pi, runtime);
|
|
139
141
|
|
|
140
142
|
// Async completion arrives as a custom message after the original tool call
|
|
141
143
|
// has returned. Give it the same compact/expanded UI as sync results while
|
|
@@ -156,6 +158,8 @@ export default function delegateExtension(
|
|
|
156
158
|
prepareArguments: normalizeDelegateArguments,
|
|
157
159
|
|
|
158
160
|
async execute(_id, params: DelegateArguments, signal, onUpdate, ctx) {
|
|
161
|
+
const browserGeneration = browserHistory.generation;
|
|
162
|
+
const captureBrowser = ctx.mode === "tui" && !params.async;
|
|
159
163
|
// Reload user-edited delegate.json at the start of every execution.
|
|
160
164
|
// Help, poll, cancel, wait, and invalid calls observe new settings, and
|
|
161
165
|
// the global concurrency cap is reconfigured so hot-reloaded maxConcurrent
|
|
@@ -243,6 +247,16 @@ export default function delegateExtension(
|
|
|
243
247
|
}
|
|
244
248
|
|
|
245
249
|
// ── Cancel action ─────────────────────────────────────────────────
|
|
250
|
+
if (params.ticketAction === "pause" || params.ticketAction === "resume") {
|
|
251
|
+
const result = runtime.tickets.handlePause({
|
|
252
|
+
ticket: params.ticket,
|
|
253
|
+
ticketAction: params.ticketAction,
|
|
254
|
+
});
|
|
255
|
+
syncDelegateStatus(ctx, runtime);
|
|
256
|
+
succeedCall();
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
|
|
246
260
|
if (params.ticketAction === "cancel") {
|
|
247
261
|
const result = runtime.tickets.handleCancel(params);
|
|
248
262
|
// A forced cancel flips the ticket to "cancelling" — keep the
|
|
@@ -331,7 +345,7 @@ export default function delegateExtension(
|
|
|
331
345
|
// visible without restarting Pi.
|
|
332
346
|
invalidateHostDepsCache();
|
|
333
347
|
try {
|
|
334
|
-
|
|
348
|
+
const result = await dispatchDelegate({
|
|
335
349
|
pi,
|
|
336
350
|
params,
|
|
337
351
|
ctx,
|
|
@@ -342,11 +356,25 @@ export default function delegateExtension(
|
|
|
342
356
|
tools: pi.getActiveTools(),
|
|
343
357
|
},
|
|
344
358
|
signal,
|
|
345
|
-
onUpdate
|
|
359
|
+
onUpdate: captureBrowser
|
|
360
|
+
? (update) => {
|
|
361
|
+
browserHistory.update(
|
|
362
|
+
_id,
|
|
363
|
+
update.details,
|
|
364
|
+
false,
|
|
365
|
+
browserGeneration,
|
|
366
|
+
);
|
|
367
|
+
onUpdate?.(update);
|
|
368
|
+
}
|
|
369
|
+
: onUpdate,
|
|
346
370
|
callSpan,
|
|
347
371
|
runtime,
|
|
348
372
|
});
|
|
373
|
+
if (captureBrowser)
|
|
374
|
+
browserHistory.update(_id, result.details, true, browserGeneration);
|
|
375
|
+
return result;
|
|
349
376
|
} catch (err) {
|
|
377
|
+
if (captureBrowser) browserHistory.fail(_id, err, browserGeneration);
|
|
350
378
|
failCall();
|
|
351
379
|
throw err;
|
|
352
380
|
}
|
package/format.ts
CHANGED
|
@@ -586,6 +586,7 @@ export function latestActivity(p: TaskProgress): ToolActivity | null {
|
|
|
586
586
|
* - "last: read src/bar.ts" after a tool completes and the model is thinking
|
|
587
587
|
* - "thinking" when no activity has been recorded yet */
|
|
588
588
|
export function formatActivityLabel(p: TaskProgress): string {
|
|
589
|
+
if (p.paused) return "paused between turns";
|
|
589
590
|
const activity = inFlightActivity(p) ?? latestActivity(p);
|
|
590
591
|
if (!activity) return "thinking";
|
|
591
592
|
const call = sanitizeTerminalLine(
|
|
@@ -599,6 +600,7 @@ export function formatActivityLabel(p: TaskProgress): string {
|
|
|
599
600
|
* as {@link formatActivityLabel} but adds elapsed time for in-flight tools and
|
|
600
601
|
* a completion/error icon for finished ones. */
|
|
601
602
|
export function compactActivity(p: TaskProgress): string {
|
|
603
|
+
if (p.paused) return "paused between turns";
|
|
602
604
|
const activity = inFlightActivity(p) ?? latestActivity(p);
|
|
603
605
|
if (!activity) return "thinking…";
|
|
604
606
|
const call = sanitizeTerminalLine(
|
|
@@ -656,18 +658,31 @@ export function findTouchedOverlaps(
|
|
|
656
658
|
results: readonly {
|
|
657
659
|
attributedFiles?: string[];
|
|
658
660
|
workspace?: WorkspaceMode;
|
|
661
|
+
serializedGroup?: number;
|
|
662
|
+
incomplete?: string;
|
|
659
663
|
}[],
|
|
660
664
|
): string[] {
|
|
661
|
-
const
|
|
665
|
+
const owners = new Map<string, (typeof results)[number][]>();
|
|
666
|
+
const overlaps = new Set<string>();
|
|
662
667
|
for (const r of results) {
|
|
663
|
-
for (const f of r.attributedFiles ?? []) {
|
|
664
|
-
|
|
668
|
+
for (const f of new Set(r.attributedFiles ?? [])) {
|
|
669
|
+
const previous = owners.get(f) ?? [];
|
|
670
|
+
if (
|
|
671
|
+
previous.some(
|
|
672
|
+
(other) =>
|
|
673
|
+
r.serializedGroup === undefined ||
|
|
674
|
+
r.serializedGroup !== other.serializedGroup ||
|
|
675
|
+
r.incomplete !== undefined ||
|
|
676
|
+
other.incomplete !== undefined,
|
|
677
|
+
)
|
|
678
|
+
) {
|
|
679
|
+
overlaps.add(f);
|
|
680
|
+
}
|
|
681
|
+
previous.push(r);
|
|
682
|
+
owners.set(f, previous);
|
|
665
683
|
}
|
|
666
684
|
}
|
|
667
|
-
return [...
|
|
668
|
-
.filter(([, count]) => count > 1)
|
|
669
|
-
.map(([file]) => file)
|
|
670
|
-
.sort();
|
|
685
|
+
return [...overlaps].sort();
|
|
671
686
|
}
|
|
672
687
|
|
|
673
688
|
/**
|
|
@@ -680,5 +695,5 @@ export function findTouchedOverlaps(
|
|
|
680
695
|
*/
|
|
681
696
|
export function formatTouchedOverlapWarning(overlaps: string[]): string | null {
|
|
682
697
|
if (!overlaps.length) return null;
|
|
683
|
-
return `WARNING:
|
|
698
|
+
return `WARNING: Tasks without a verified ordering reported touching the same file(s): ${overlaps.join(", ")}. File reports do not prove simultaneous writes or a conflict; completed writes are not rolled back.`;
|
|
684
699
|
}
|
package/lifecycle.ts
CHANGED
|
@@ -361,6 +361,8 @@ export function updateProgressFromRun(
|
|
|
361
361
|
u.durationMs,
|
|
362
362
|
);
|
|
363
363
|
p.lastActivityAt = u.lastActivityAt;
|
|
364
|
+
p.assistantPreview = u.assistantPreview;
|
|
365
|
+
p.activity = u.activity;
|
|
364
366
|
p.activities = mergeToolActivities(p.activities, u.activities);
|
|
365
367
|
p.failureKind = u.failureKind;
|
|
366
368
|
}
|
|
@@ -1546,6 +1548,7 @@ async function runTaskAttempt(
|
|
|
1546
1548
|
timing.taskStartedAt,
|
|
1547
1549
|
timing.deadlineAt,
|
|
1548
1550
|
env.config,
|
|
1551
|
+
env.pause ? { controller: env.pause, index: p.index } : undefined,
|
|
1549
1552
|
);
|
|
1550
1553
|
|
|
1551
1554
|
accounting.cumulativeTokens += r.tokens;
|
package/manual.ts
CHANGED
|
@@ -114,7 +114,7 @@ export function getSubagentManualMarkdown(
|
|
|
114
114
|
"",
|
|
115
115
|
"The three handles have different lifetimes:",
|
|
116
116
|
"",
|
|
117
|
-
"- **ticket** — controls one async batch with `poll`, `wait`, or `cancel`.",
|
|
117
|
+
"- **ticket** — controls one async batch with `poll`, `wait`, `pause`, `resume`, or `cancel`.",
|
|
118
118
|
"- **sessionId** — a caller-chosen key for a live multi-turn worker, retained until close or parent shutdown.",
|
|
119
119
|
"- **resumeFrom** — an absolute `.jsonl` transcript path used to recover an interrupted worker.",
|
|
120
120
|
"",
|
|
@@ -139,6 +139,7 @@ export function getSubagentManualMarkdown(
|
|
|
139
139
|
...builtinLines,
|
|
140
140
|
"",
|
|
141
141
|
"Start with a built-in and its configured defaults. Set only `agent` and `prompt`; omit `model`, `thinking`, `tools`, and `workspace` unless the user requests an override or a concrete task requirement makes the built-in default unsuitable.",
|
|
142
|
+
"A scout needs no scratch copy with its read-only defaults. Adding `bash` makes it write-capable regardless of its name; an explicit scratch copy can then be appropriate for disposable shell-based investigation.",
|
|
142
143
|
"",
|
|
143
144
|
"Prefer `default` for general work: it is the only built-in guaranteed to run the parent's exact model and thinking. `scout`/`coder`/`reviewer` apply any configured delegate.json tiers (`agentOverrides`, `agentOverridesByParentModel`) and may run a different model or thinking level than the parent. `scout` is read-only; `coder` and `reviewer` use the shared workspace unless overridden. Choose a specialist for its role, not its name.",
|
|
144
145
|
"",
|
|
@@ -168,6 +169,12 @@ export function getSubagentManualMarkdown(
|
|
|
168
169
|
"",
|
|
169
170
|
schemaTable(delegateArgumentsSchema.properties),
|
|
170
171
|
"",
|
|
172
|
+
"## Pause and Resume",
|
|
173
|
+
"",
|
|
174
|
+
'Use `delegate({ ticketAction: "pause", ticket: "<id>" })` to pause an async batch, and `ticketAction: "resume"` to continue its same live sessions. These controls return a snapshot immediately.',
|
|
175
|
+
"Pause finishes each current model response and its tool calls, then blocks before the next model request. Not-yet-started tasks stay queued. `pausing` means an operation is still finishing; `paused` means all remaining work has reached a checkpoint. A task that finishes naturally need not pause.",
|
|
176
|
+
"The inactivity watchdog is suspended at a paused turn boundary, but explicit wall-clock `deadlineMs` budgets continue. Wait does not resume. Cancel and shutdown still abort paused work. Live sessions, concurrency slots, workspace reservations, and scratch/isolated workspaces remain held; pause does not survive Pi exit or reload. Isolated preparation/application already in progress finishes before pausing; background processes are not frozen and files may be unfinished.",
|
|
177
|
+
"",
|
|
171
178
|
"## Session Reuse",
|
|
172
179
|
"",
|
|
173
180
|
"When `sessionId` is set, the subagent is kept alive in a pool for the duration of the pi session.",
|
|
@@ -229,6 +236,8 @@ export function getSubagentManualMarkdown(
|
|
|
229
236
|
"",
|
|
230
237
|
'- `delegate({ ticketAction: "poll" })` \u2014 list all tickets',
|
|
231
238
|
'- `delegate({ ticketAction: "poll", ticket: "abc123" })` \u2014 take one progress snapshot',
|
|
239
|
+
'- `delegate({ ticketAction: "pause", ticket: "abc123" })` \u2014 pause between turns, not after the entire task',
|
|
240
|
+
'- `delegate({ ticketAction: "resume", ticket: "abc123" })` \u2014 continue the same live sessions',
|
|
232
241
|
'- `delegate({ ticketAction: "wait", ticket: "abc123" })` \u2014 block until finished; omit `timeoutMs` when the result is needed this turn',
|
|
233
242
|
'- `delegate({ ticketAction: "wait", ticket: "abc123", timeoutMs: 600000 })` \u2014 bounded wait; timeout includes the latest snapshot, so do not poll afterward',
|
|
234
243
|
'- `delegate({ ticketAction: "cancel", ticket: "abc123" })` \u2014 preview activity and partial effects before cancelling',
|
package/package.json
CHANGED
package/pause.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Cooperative, in-memory ticket pause. Active operations finish; participants
|
|
2
|
+
* park at a checkpoint before starting their next operation. This neither
|
|
3
|
+
* freezes child processes nor releases workspace reservations/concurrency. */
|
|
4
|
+
export type PauseState = "running" | "pausing" | "paused";
|
|
5
|
+
|
|
6
|
+
export class PauseController {
|
|
7
|
+
private requested = false;
|
|
8
|
+
private readonly active = new Set<number>();
|
|
9
|
+
private readonly parked = new Set<number>();
|
|
10
|
+
private readonly wake = new Set<() => void>();
|
|
11
|
+
|
|
12
|
+
constructor(private readonly onChange: () => void = () => {}) {}
|
|
13
|
+
|
|
14
|
+
get state(): PauseState {
|
|
15
|
+
if (!this.requested) return "running";
|
|
16
|
+
return [...this.active].every((index) => this.parked.has(index))
|
|
17
|
+
? "paused"
|
|
18
|
+
: "pausing";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
isParked(index: number): boolean {
|
|
22
|
+
return this.requested && this.parked.has(index);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private publish(): void {
|
|
26
|
+
try {
|
|
27
|
+
this.onChange();
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error("[delegate] pause state notification failed", error);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
pause(): void {
|
|
34
|
+
if (this.requested) return;
|
|
35
|
+
this.requested = true;
|
|
36
|
+
this.publish();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
resume(): void {
|
|
40
|
+
if (!this.requested) return;
|
|
41
|
+
this.requested = false;
|
|
42
|
+
for (const wake of this.wake) wake();
|
|
43
|
+
this.publish();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
enter(index: number): void {
|
|
47
|
+
this.active.add(index);
|
|
48
|
+
this.publish();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
leave(index: number): void {
|
|
52
|
+
this.active.delete(index);
|
|
53
|
+
this.parked.delete(index);
|
|
54
|
+
this.publish();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Abort unblocks the checkpoint without clearing another task's pause.
|
|
58
|
+
* Callers still observe their signal and take their normal cancellation path. */
|
|
59
|
+
async checkpoint(index: number, signal?: AbortSignal): Promise<void> {
|
|
60
|
+
if (!this.requested || signal?.aborted) return;
|
|
61
|
+
this.parked.add(index);
|
|
62
|
+
this.publish();
|
|
63
|
+
try {
|
|
64
|
+
while (this.requested && !signal?.aborted) {
|
|
65
|
+
await new Promise<void>((resolve) => {
|
|
66
|
+
const wake = () => {
|
|
67
|
+
this.wake.delete(wake);
|
|
68
|
+
signal?.removeEventListener("abort", wake);
|
|
69
|
+
resolve();
|
|
70
|
+
};
|
|
71
|
+
this.wake.add(wake);
|
|
72
|
+
signal?.addEventListener("abort", wake, { once: true });
|
|
73
|
+
if (signal?.aborted) wake();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
this.parked.delete(index);
|
|
78
|
+
this.publish();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
package/render-branches.ts
CHANGED
|
@@ -54,6 +54,7 @@ export interface RenderState {
|
|
|
54
54
|
|
|
55
55
|
/** Shared inputs for the partial and final render branches. */
|
|
56
56
|
export interface BranchCtx {
|
|
57
|
+
pauseState?: import("./pause.ts").PauseState;
|
|
57
58
|
progress: TaskProgress[];
|
|
58
59
|
taskResults: (TaskResult | { error: string })[];
|
|
59
60
|
total: number;
|
|
@@ -98,7 +99,11 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
98
99
|
// Keep the live and final summaries in the same order so the header remains
|
|
99
100
|
// easy to scan as a partial result resolves into its final form.
|
|
100
101
|
const headerParts: string[] = [];
|
|
101
|
-
|
|
102
|
+
const paused = progress.filter(
|
|
103
|
+
(p) => p.status === "running" && p.paused,
|
|
104
|
+
).length;
|
|
105
|
+
if (running > paused) headerParts.push(`${running - paused} running`);
|
|
106
|
+
if (paused > 0) headerParts.push(`${paused} paused`);
|
|
102
107
|
headerParts.push(`${finished}/${total} finished`);
|
|
103
108
|
if (failed > 0) headerParts.push(`${failed} failed`);
|
|
104
109
|
headerParts.push(
|
|
@@ -111,7 +116,9 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
111
116
|
const stateLabel =
|
|
112
117
|
ctx.ticketStatus === "cancelling"
|
|
113
118
|
? `${theme.fg("error", "■ cancelling")} · `
|
|
114
|
-
: ""
|
|
119
|
+
: ctx.pauseState && ctx.pauseState !== "running"
|
|
120
|
+
? `${theme.fg("warning", `Ⅱ ${ctx.pauseState}`)} · `
|
|
121
|
+
: "";
|
|
115
122
|
const expandHint = toolExpandHint();
|
|
116
123
|
const detailHint =
|
|
117
124
|
!expanded && running > 0 && expandHint
|
|
@@ -362,7 +369,11 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
362
369
|
if (ticketId && ticketIsLive) {
|
|
363
370
|
// Background ticket — frame it as in-progress, not a finished result.
|
|
364
371
|
const ticketParts = [`${finalized}/${total} finished`];
|
|
365
|
-
|
|
372
|
+
const paused = progress.filter(
|
|
373
|
+
(p) => p.status === "running" && p.paused,
|
|
374
|
+
).length;
|
|
375
|
+
if (running > paused) ticketParts.push(`${running - paused} active`);
|
|
376
|
+
if (paused > 0) ticketParts.push(`${paused} paused`);
|
|
366
377
|
if (pending > 0) ticketParts.push(`${pending} queued`);
|
|
367
378
|
if (failed > 0) ticketParts.push(`${failed} failed`);
|
|
368
379
|
if (cancelled > 0) ticketParts.push(`${cancelled} cancelled`);
|
|
@@ -373,7 +384,9 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
|
|
|
373
384
|
const stateLabel =
|
|
374
385
|
ticketStatus === "cancelling"
|
|
375
386
|
? ` ${theme.fg("error", "cancelling")}`
|
|
376
|
-
: ""
|
|
387
|
+
: ctx.pauseState && ctx.pauseState !== "running"
|
|
388
|
+
? ` ${theme.fg("warning", ctx.pauseState)}`
|
|
389
|
+
: "";
|
|
377
390
|
lines.push(
|
|
378
391
|
`${glyph}${stateLabel} ${theme.fg("muted", `${ticketLabel}${ticketParts.join(" · ")}`)}${detailHint}`,
|
|
379
392
|
"",
|
package/render-result.ts
CHANGED
package/runner.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
AgentSession,
|
|
4
4
|
AgentSessionEvent,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { AssistantPreview } from "./assistant-preview.ts";
|
|
6
7
|
import {
|
|
7
8
|
getGitChangedFiles,
|
|
8
9
|
extractAttributedFromActivities,
|
|
@@ -127,6 +128,10 @@ export async function runAgentSession(
|
|
|
127
128
|
deadlineAt?: number,
|
|
128
129
|
/** Dispatch-scoped delegate.json snapshot for the stall timeout. */
|
|
129
130
|
delegateConfig?: import("./config.ts").DelegateConfig,
|
|
131
|
+
pause?: {
|
|
132
|
+
controller: import("./pause.ts").PauseController;
|
|
133
|
+
index: number;
|
|
134
|
+
},
|
|
130
135
|
): Promise<{
|
|
131
136
|
output: string;
|
|
132
137
|
error?: string;
|
|
@@ -176,6 +181,8 @@ export async function runAgentSession(
|
|
|
176
181
|
let recoveryBarrier: QuiescenceBarrier | undefined;
|
|
177
182
|
let abandonmentSafety: Promise<void> | undefined;
|
|
178
183
|
let unsubscribeFull: (() => void) | undefined;
|
|
184
|
+
let unsubscribePause: (() => void) | undefined;
|
|
185
|
+
let pausedAtTurnBoundary = false;
|
|
179
186
|
let unsubscribeRecovery: (() => void) | undefined;
|
|
180
187
|
const safeLog = (message: string, error?: unknown): void => {
|
|
181
188
|
try {
|
|
@@ -187,11 +194,18 @@ export async function runAgentSession(
|
|
|
187
194
|
const removeFullListener = (): void => {
|
|
188
195
|
const remove = unsubscribeFull;
|
|
189
196
|
unsubscribeFull = undefined;
|
|
197
|
+
const removePause = unsubscribePause;
|
|
198
|
+
unsubscribePause = undefined;
|
|
190
199
|
try {
|
|
191
200
|
remove?.();
|
|
192
201
|
} catch (error) {
|
|
193
202
|
safeLog("[delegate] full AgentSession listener cleanup failed", error);
|
|
194
203
|
}
|
|
204
|
+
try {
|
|
205
|
+
removePause?.();
|
|
206
|
+
} catch (error) {
|
|
207
|
+
safeLog("[delegate] turn-pause listener cleanup failed", error);
|
|
208
|
+
}
|
|
195
209
|
};
|
|
196
210
|
const removeRecoveryListener = (): void => {
|
|
197
211
|
const remove = unsubscribeRecovery;
|
|
@@ -206,6 +220,7 @@ export async function runAgentSession(
|
|
|
206
220
|
}
|
|
207
221
|
};
|
|
208
222
|
const activities: ToolActivity[] = [];
|
|
223
|
+
const assistantPreview = new AssistantPreview();
|
|
209
224
|
const pendingById = new Map<string, ToolActivity>();
|
|
210
225
|
let notifyCancellationRequested!: () => void;
|
|
211
226
|
const cancellationRequested = new Promise<void>((resolve) => {
|
|
@@ -377,6 +392,8 @@ export async function runAgentSession(
|
|
|
377
392
|
try {
|
|
378
393
|
const cancellationSource = currentCancellationSource();
|
|
379
394
|
onProgress({
|
|
395
|
+
assistantPreview: assistantPreview.text,
|
|
396
|
+
activity: phase,
|
|
380
397
|
tokens: delta,
|
|
381
398
|
toolUses,
|
|
382
399
|
durationMs: Date.now() - startTime,
|
|
@@ -415,7 +432,13 @@ export async function runAgentSession(
|
|
|
415
432
|
};
|
|
416
433
|
const armStallWatchdog = (graceMs = 0) => {
|
|
417
434
|
clearStallWatchdog();
|
|
418
|
-
if (
|
|
435
|
+
if (
|
|
436
|
+
!stallTimeoutMs ||
|
|
437
|
+
stalled ||
|
|
438
|
+
signal?.aborted ||
|
|
439
|
+
deadlineExceeded ||
|
|
440
|
+
pausedAtTurnBoundary
|
|
441
|
+
)
|
|
419
442
|
return;
|
|
420
443
|
|
|
421
444
|
const grace = Number.isFinite(graceMs) && graceMs > 0 ? graceMs : 0;
|
|
@@ -625,6 +648,35 @@ export async function runAgentSession(
|
|
|
625
648
|
// result, isError) — AgentSession forwards the underlying agent events
|
|
626
649
|
// verbatim. Retry and compaction events are handled below; queue/bookkeeping
|
|
627
650
|
// events and thinking changes are intentionally ignored.
|
|
651
|
+
// AgentSession subscribers are notifications, not an awaited barrier.
|
|
652
|
+
// Pi core explicitly awaits Agent subscribers before proceeding from
|
|
653
|
+
// turn_start to the next model request, including retries/continuations.
|
|
654
|
+
// Never gate turn_end: a final turn should be allowed to finish the task.
|
|
655
|
+
if (pause) {
|
|
656
|
+
unsubscribePause = session.agent.subscribe(async (event, turnSignal) => {
|
|
657
|
+
if (event.type !== "turn_start" || pause.controller.state === "running")
|
|
658
|
+
return;
|
|
659
|
+
pausedAtTurnBoundary = true;
|
|
660
|
+
clearStallWatchdog();
|
|
661
|
+
try {
|
|
662
|
+
await pause.controller.checkpoint(
|
|
663
|
+
pause.index,
|
|
664
|
+
turnSignal && signal
|
|
665
|
+
? AbortSignal.any([turnSignal, signal])
|
|
666
|
+
: (turnSignal ?? signal),
|
|
667
|
+
);
|
|
668
|
+
if (turnSignal?.aborted || signal?.aborted) {
|
|
669
|
+
throw new Error(
|
|
670
|
+
"Paused turn cancelled before the next model request",
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
} finally {
|
|
674
|
+
pausedAtTurnBoundary = false;
|
|
675
|
+
noteActivity("resuming at turn boundary");
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
628
680
|
unsubscribeFull = session.subscribe((event: AgentSessionEvent) => {
|
|
629
681
|
barrier.noteEvent();
|
|
630
682
|
recoveryBarrier?.noteEvent();
|
|
@@ -678,12 +730,14 @@ export async function runAgentSession(
|
|
|
678
730
|
case "message_start":
|
|
679
731
|
case "message_update":
|
|
680
732
|
if (event.message?.role === "assistant") {
|
|
733
|
+
assistantPreview.update(extractOutput([event.message]));
|
|
681
734
|
rememberPartialAssistant(event.message);
|
|
682
735
|
}
|
|
683
736
|
noteActivity("streaming model output");
|
|
684
737
|
break;
|
|
685
738
|
case "message_end":
|
|
686
739
|
if (event.message.role === "assistant") {
|
|
740
|
+
assistantPreview.finish(extractOutput([event.message]));
|
|
687
741
|
assistantMessagesForAttempt.push(event.message);
|
|
688
742
|
// Keep a text-bearing partial if the host follows a provider
|
|
689
743
|
// exception with an empty synthetic failure message. A real
|