pi-background-tasks 1.0.6 → 2.0.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 +7 -7
- package/TESTING.md +3 -3
- package/TEST_PLAN.md +2 -2
- package/docs/INDEX.md +25 -25
- package/docs/choose-a-workflow.md +4 -4
- package/docs/commands/bg-clear.md +1 -1
- package/docs/commands/bg-update.md +1 -1
- package/docs/commands/bg.md +1 -1
- package/docs/commands/fusion-models.md +1 -1
- package/docs/commands/fusion.md +5 -8
- package/docs/commands/jobs.md +1 -1
- package/docs/commands/kill.md +1 -1
- package/docs/commands/logs.md +1 -1
- package/docs/commands/task-manager.md +2 -2
- package/docs/concepts/completion-delivery.md +1 -0
- package/docs/getting-started.md +1 -1
- package/docs/manifest.json +59 -50
- package/docs/read-before-edit.md +1 -0
- package/docs/reference/runtime-contracts.md +55 -51
- package/docs/reference/shortcuts-and-dock.md +2 -2
- package/docs/subsystems/background-task-runtime.md +7 -1
- package/docs/subsystems/docs-freshness-gate.md +4 -4
- package/docs/subsystems/fusion.md +15 -11
- package/docs/subsystems/host-ui-and-telemetry.md +1 -1
- package/docs/tools/bg_delegate.md +1 -1
- package/docs/tools/bg_kill.md +1 -1
- package/docs/tools/bg_logs.md +1 -1
- package/docs/tools/bg_result.md +14 -10
- package/docs/tools/bg_run.md +1 -1
- package/docs/tools/bg_run_pi_attested.md +1 -1
- package/docs/tools/bg_status.md +1 -1
- package/docs/tools/fusion_investigate.md +6 -4
- package/docs/tools/fusion_reason.md +5 -5
- package/docs/tools/fusion_research.md +6 -2
- package/docs/tools/fusion_validate.md +5 -3
- package/package.json +1 -1
- package/src/core/common.ts +50 -2
- package/src/core/fusion/artifacts.ts +106 -13
- package/src/core/fusion/budget.ts +12 -4
- package/src/core/fusion/evaluation.ts +61 -0
- package/src/core/fusion/orchestrator.ts +270 -73
- package/src/core/fusion/pi-child.ts +6 -0
- package/src/core/fusion/prompts.ts +1 -0
- package/src/core/fusion/result-package.ts +385 -0
- package/src/core/fusion/types.ts +19 -1
- package/src/core/registry.ts +187 -20
- package/src/delegate-extension.ts +130 -24
- package/src/extension.ts +17 -6
- package/src/fusion-extension.ts +308 -154
package/src/core/registry.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type KillKind,
|
|
29
29
|
type StartAttestedPiTaskOptions,
|
|
30
30
|
type StartDelegateTaskOptions,
|
|
31
|
+
type StartManagedTaskOptions,
|
|
31
32
|
type StartTaskOptions,
|
|
32
33
|
type TaskContextUsage,
|
|
33
34
|
type TaskStatus,
|
|
@@ -740,11 +741,13 @@ export class BackgroundTaskRegistry {
|
|
|
740
741
|
this.killProcess = options.killProcess ?? process.kill.bind(process);
|
|
741
742
|
this.platform = options.platform ?? process.platform;
|
|
742
743
|
this.env = options.env ?? process.env;
|
|
743
|
-
this.killTree =
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
744
|
+
this.killTree =
|
|
745
|
+
options.killTree ??
|
|
746
|
+
((pid, phase, signal) => {
|
|
747
|
+
const taskkillOptions: WindowsTaskkillOptions =
|
|
748
|
+
signal === undefined ? { env: this.env } : { env: this.env, signal };
|
|
749
|
+
return runWindowsTaskkill(pid, phase, taskkillOptions);
|
|
750
|
+
});
|
|
748
751
|
this.makeTaskIdFn = options.makeTaskId ?? defaultTaskId;
|
|
749
752
|
this.now = options.now ?? Date.now;
|
|
750
753
|
this.maxOutputBytes = options.maxOutputBytes ?? MAX_OUTPUT_BYTES;
|
|
@@ -869,7 +872,8 @@ export class BackgroundTaskRegistry {
|
|
|
869
872
|
let commandToSpawn = normalizedCommand;
|
|
870
873
|
if (piTelemetryRequested) {
|
|
871
874
|
if (baseInvocation.dialect === 'posix') {
|
|
872
|
-
if (piTelemetryLaunch === undefined)
|
|
875
|
+
if (piTelemetryLaunch === undefined)
|
|
876
|
+
throw new Error('Pi telemetry launch spec was not resolved');
|
|
873
877
|
const wrapperAbsPath = join(dir.abs, `${id}.pi-telemetry-wrapper.cjs`);
|
|
874
878
|
await writeFile(
|
|
875
879
|
wrapperAbsPath,
|
|
@@ -962,6 +966,128 @@ export class BackgroundTaskRegistry {
|
|
|
962
966
|
}
|
|
963
967
|
}
|
|
964
968
|
|
|
969
|
+
/**
|
|
970
|
+
* Track an in-process asynchronous workflow through the same durable task,
|
|
971
|
+
* notification, status, log, and cancellation surfaces as child processes.
|
|
972
|
+
* The supplied completion promise must own all workflow cleanup before it
|
|
973
|
+
* settles; terminal publication happens only after that settlement.
|
|
974
|
+
*/
|
|
975
|
+
async startManagedTask(
|
|
976
|
+
ctx: BackgroundTaskContext,
|
|
977
|
+
request: StartManagedTaskOptions,
|
|
978
|
+
): Promise<BgTask> {
|
|
979
|
+
if (this.shuttingDown)
|
|
980
|
+
throw new Error('Cannot start a managed background task while Pi is shutting down');
|
|
981
|
+
if (!/^[a-zA-Z0-9_.-]+$/u.test(request.id))
|
|
982
|
+
throw new Error(`Managed background task id is invalid: ${request.id}`);
|
|
983
|
+
if (this.tasks.has(request.id))
|
|
984
|
+
throw new Error(`Background task id already exists: ${request.id}`);
|
|
985
|
+
|
|
986
|
+
const dir = await this.ensureRuntimeDir(ctx);
|
|
987
|
+
const outputAbsPath = join(dir.abs, `${request.id}.output`);
|
|
988
|
+
const metadataAbsPath = join(dir.abs, `${request.id}.json`);
|
|
989
|
+
const outputPath = join(dir.display, `${request.id}.output`);
|
|
990
|
+
const task: BgTask = {
|
|
991
|
+
id: request.id,
|
|
992
|
+
name: normalizeTaskName(request.name) ?? 'Managed background task',
|
|
993
|
+
command: request.command,
|
|
994
|
+
description: request.description,
|
|
995
|
+
status: 'running',
|
|
996
|
+
outputPath,
|
|
997
|
+
outputAbsPath,
|
|
998
|
+
metadataAbsPath,
|
|
999
|
+
cwd: ctx.cwd,
|
|
1000
|
+
startTime: this.now(),
|
|
1001
|
+
exitCode: undefined,
|
|
1002
|
+
pid: undefined,
|
|
1003
|
+
bytesWritten: 0,
|
|
1004
|
+
isAgent: request.isAgent,
|
|
1005
|
+
notified: false,
|
|
1006
|
+
notifyOnCompletion: request.notifyOnCompletion,
|
|
1007
|
+
triggerOnCompletion: request.triggerOnCompletion,
|
|
1008
|
+
fusion: request.fusion,
|
|
1009
|
+
managedCancel: request.cancel,
|
|
1010
|
+
managedStopWaitMs: request.stopWaitMs,
|
|
1011
|
+
terminalPublicationGate: request.terminalPublicationGate,
|
|
1012
|
+
waiters: [],
|
|
1013
|
+
};
|
|
1014
|
+
this.tasks.set(task.id, task);
|
|
1015
|
+
const stream = createWriteStream(outputAbsPath, { flags: 'a', encoding: 'utf8' });
|
|
1016
|
+
task.stream = stream;
|
|
1017
|
+
stream.on('error', (error) => {
|
|
1018
|
+
task.error = `Output file write failed: ${error.message}`;
|
|
1019
|
+
if (task.status === 'running' && !task.managedCancelRequested) {
|
|
1020
|
+
task.managedCancelRequested = true;
|
|
1021
|
+
try {
|
|
1022
|
+
request.cancel();
|
|
1023
|
+
} catch (cancelError) {
|
|
1024
|
+
task.error = `${task.error}; cancellation failed: ${BackgroundTaskRegistry.errorMessage(cancelError)}`;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
try {
|
|
1030
|
+
await this.writeMetadata(task);
|
|
1031
|
+
this.onChange();
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
this.tasks.delete(task.id);
|
|
1034
|
+
if (!stream.destroyed) stream.destroy();
|
|
1035
|
+
try {
|
|
1036
|
+
request.cancel();
|
|
1037
|
+
} catch (cancelError) {
|
|
1038
|
+
this.logger.error(
|
|
1039
|
+
`[background-tasks] managed task cancellation after metadata failure also failed for ${task.id}:`,
|
|
1040
|
+
cancelError,
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
throw new Error(
|
|
1044
|
+
`Failed to register managed background task: ${BackgroundTaskRegistry.errorMessage(error)}`,
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
void request.completion
|
|
1049
|
+
.then(
|
|
1050
|
+
() => this.finalizeTask(task, 'completed', 0),
|
|
1051
|
+
(error: unknown) => {
|
|
1052
|
+
const message = BackgroundTaskRegistry.errorMessage(error);
|
|
1053
|
+
const killed = task.killKind === 'user' || task.killKind === 'shutdown';
|
|
1054
|
+
return this.finalizeTask(task, killed ? 'killed' : 'failed', null, undefined, message);
|
|
1055
|
+
},
|
|
1056
|
+
)
|
|
1057
|
+
.catch((error: unknown) => {
|
|
1058
|
+
this.logger.error(
|
|
1059
|
+
`[background-tasks] managed task finalization failed for ${task.id}:`,
|
|
1060
|
+
error,
|
|
1061
|
+
);
|
|
1062
|
+
});
|
|
1063
|
+
return task;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
async updateManagedTask(task: BgTask, state: string, line?: string): Promise<void> {
|
|
1067
|
+
if (task.status !== 'running' || task.fusion === undefined) return;
|
|
1068
|
+
task.fusion.state = state;
|
|
1069
|
+
if (line !== undefined && line.length > 0) this.writeNotice(task, `${line}\n`);
|
|
1070
|
+
await this.writeMetadata(task);
|
|
1071
|
+
this.onChange();
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** Claim deferred Fusion usage exactly once before returning it from bg_result. */
|
|
1075
|
+
async claimFusionUsage(task: BgTask): Promise<boolean> {
|
|
1076
|
+
if (task.fusion === undefined) throw new Error(`Task ${task.id} is not a Fusion task`);
|
|
1077
|
+
let claimed = false;
|
|
1078
|
+
const write = async () => {
|
|
1079
|
+
if (!task.fusion || task.fusion.usageDelivered) return;
|
|
1080
|
+
task.fusion.usageDelivered = true;
|
|
1081
|
+
await writeJsonAtomic(task.metadataAbsPath, snapshot(task));
|
|
1082
|
+
claimed = true;
|
|
1083
|
+
};
|
|
1084
|
+
const previous = task.metadataWriteChain ?? Promise.resolve();
|
|
1085
|
+
const next = previous.then(write, write);
|
|
1086
|
+
task.metadataWriteChain = next.catch(() => undefined);
|
|
1087
|
+
await next;
|
|
1088
|
+
return claimed;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
965
1091
|
/**
|
|
966
1092
|
* Start a prepared delegate child.
|
|
967
1093
|
*
|
|
@@ -1420,15 +1546,16 @@ export class BackgroundTaskRegistry {
|
|
|
1420
1546
|
task.killKind = kind;
|
|
1421
1547
|
if (reason) task.error = reason;
|
|
1422
1548
|
this.requestKill(task, 'SIGTERM');
|
|
1549
|
+
const stopWaitMs = task.managedStopWaitMs ?? this.stopWaitMs;
|
|
1423
1550
|
const stopped =
|
|
1424
|
-
this.platform === 'win32'
|
|
1425
|
-
? await this.waitForEndOrWindowsForceFailure(task,
|
|
1426
|
-
: await this.waitForEnd(task,
|
|
1551
|
+
this.platform === 'win32' && task.managedCancel === undefined
|
|
1552
|
+
? await this.waitForEndOrWindowsForceFailure(task, stopWaitMs)
|
|
1553
|
+
: await this.waitForEnd(task, stopWaitMs);
|
|
1427
1554
|
const forceFailure = this.windowsKillStates.get(task)?.forceFailure;
|
|
1428
1555
|
if (forceFailure !== undefined) throw forceFailure;
|
|
1429
1556
|
if (!stopped) {
|
|
1430
1557
|
throw new Error(
|
|
1431
|
-
`Task ${task.id} did not exit within ${formatDuration(
|
|
1558
|
+
`Task ${task.id} did not exit within ${formatDuration(stopWaitMs)} after cancellation`,
|
|
1432
1559
|
);
|
|
1433
1560
|
}
|
|
1434
1561
|
return task;
|
|
@@ -1880,7 +2007,10 @@ export class BackgroundTaskRegistry {
|
|
|
1880
2007
|
const rejectForceReady = rejectForce;
|
|
1881
2008
|
state.forcePromise = forcePromise;
|
|
1882
2009
|
void forcePromise.catch((error: unknown) => {
|
|
1883
|
-
this.logger.error(
|
|
2010
|
+
this.logger.error(
|
|
2011
|
+
`[background-tasks] Windows force tree termination failed for ${task.id}:`,
|
|
2012
|
+
error,
|
|
2013
|
+
);
|
|
1884
2014
|
});
|
|
1885
2015
|
|
|
1886
2016
|
this.clearKillEscalationTimer(task);
|
|
@@ -1922,7 +2052,11 @@ export class BackgroundTaskRegistry {
|
|
|
1922
2052
|
resolveForceReady();
|
|
1923
2053
|
return;
|
|
1924
2054
|
}
|
|
1925
|
-
const failure = this.makeWindowsForceFailure(
|
|
2055
|
+
const failure = this.makeWindowsForceFailure(
|
|
2056
|
+
task,
|
|
2057
|
+
pid,
|
|
2058
|
+
BackgroundTaskRegistry.errorMessage(error),
|
|
2059
|
+
);
|
|
1926
2060
|
this.recordWindowsForceFailure(task, failure);
|
|
1927
2061
|
rejectForceReady(failure);
|
|
1928
2062
|
},
|
|
@@ -1965,6 +2099,19 @@ export class BackgroundTaskRegistry {
|
|
|
1965
2099
|
if (task.status !== 'running') {
|
|
1966
2100
|
throw new Error(`Task ${task.id} is ${task.status}, not running`);
|
|
1967
2101
|
}
|
|
2102
|
+
if (task.managedCancel !== undefined) {
|
|
2103
|
+
if (task.managedCancelRequested) return;
|
|
2104
|
+
task.managedCancelRequested = true;
|
|
2105
|
+
try {
|
|
2106
|
+
task.managedCancel();
|
|
2107
|
+
} catch (error) {
|
|
2108
|
+
throw new Error(
|
|
2109
|
+
`Could not cancel managed task ${task.id}: ${BackgroundTaskRegistry.errorMessage(error)}`,
|
|
2110
|
+
);
|
|
2111
|
+
}
|
|
2112
|
+
task.killSignalSent = true;
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
1968
2115
|
if (!task.child) {
|
|
1969
2116
|
throw new Error(`Task ${task.id} has no child process handle`);
|
|
1970
2117
|
}
|
|
@@ -2145,6 +2292,12 @@ export class BackgroundTaskRegistry {
|
|
|
2145
2292
|
task.exitCode === undefined ? '' : `\n <exit-code>${String(task.exitCode)}</exit-code>`;
|
|
2146
2293
|
const error = task.error ? `\n <error>${escapeXml(task.error)}</error>` : '';
|
|
2147
2294
|
const taskName = taskDisplayName(task);
|
|
2295
|
+
const guidance =
|
|
2296
|
+
task.fusion === undefined
|
|
2297
|
+
? 'Terminal state and output metadata are durable. Do not call bg_status to reconfirm; use bg_logs only if output is needed.'
|
|
2298
|
+
: task.status === 'completed'
|
|
2299
|
+
? `Fusion result is durably committed at ${task.fusion.artifactDir}. Call bg_result({taskId:${JSON.stringify(task.id)}}) once to retrieve it; do not poll.`
|
|
2300
|
+
: `Fusion ended ${task.status}. Inspect the preserved artifacts at ${task.fusion.artifactDir}; do not poll.`;
|
|
2148
2301
|
const content = [
|
|
2149
2302
|
'<background-task-notification>',
|
|
2150
2303
|
` <task-id>${task.id}</task-id>`,
|
|
@@ -2154,7 +2307,7 @@ export class BackgroundTaskRegistry {
|
|
|
2154
2307
|
error,
|
|
2155
2308
|
` <output-file>${escapeXml(task.outputPath)}</output-file>`,
|
|
2156
2309
|
` <summary>${escapeXml(`Background task ${JSON.stringify(taskName)} ${task.status}`)}</summary>`,
|
|
2157
|
-
|
|
2310
|
+
` <guidance>${escapeXml(guidance)}</guidance>`,
|
|
2158
2311
|
'</background-task-notification>',
|
|
2159
2312
|
]
|
|
2160
2313
|
.filter(Boolean)
|
|
@@ -2250,13 +2403,27 @@ export class BackgroundTaskRegistry {
|
|
|
2250
2403
|
for (const waiter of task.waiters.splice(0)) waiter();
|
|
2251
2404
|
this.onChange();
|
|
2252
2405
|
this.publishTerminal(task);
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2406
|
+
let deliveryGateReady = true;
|
|
2407
|
+
if (task.terminalPublicationGate !== undefined) {
|
|
2408
|
+
try {
|
|
2409
|
+
await task.terminalPublicationGate;
|
|
2410
|
+
} catch (error) {
|
|
2411
|
+
deliveryGateReady = false;
|
|
2412
|
+
this.logger.error(
|
|
2413
|
+
`[background-tasks] completion delivery gate failed for ${task.id}:`,
|
|
2414
|
+
error,
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
if (deliveryGateReady) {
|
|
2419
|
+
try {
|
|
2420
|
+
this.notifyCompletion(task);
|
|
2421
|
+
} catch (notificationError) {
|
|
2422
|
+
this.logger.error(
|
|
2423
|
+
`[background-tasks] notification failed for ${task.id}:`,
|
|
2424
|
+
notificationError,
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
2260
2427
|
}
|
|
2261
2428
|
try {
|
|
2262
2429
|
await this.writeMetadata(task);
|
|
@@ -10,6 +10,9 @@ import { Text } from '@earendil-works/pi-tui';
|
|
|
10
10
|
import { Type, type Static } from 'typebox';
|
|
11
11
|
import type { BgTask, BgTaskSnapshot, StartDelegateTaskOptions } from './core/common.js';
|
|
12
12
|
import { truncateChars } from './core/common.js';
|
|
13
|
+
import { sha256Buffer } from './core/attested-pi-run.js';
|
|
14
|
+
import { readFusionCommittedResult } from './core/fusion/result-package.js';
|
|
15
|
+
import { cloneFusionUsage, type FusionUsage, type FusionWorkflowId } from './core/fusion/types.js';
|
|
13
16
|
import {
|
|
14
17
|
DELEGATE_AUTO_DELIVER_MODES,
|
|
15
18
|
DELEGATE_CAPABILITIES,
|
|
@@ -74,7 +77,10 @@ const DelegateParams = Type.Object(
|
|
|
74
77
|
provider: Type.String({ description: 'Exact provider name to pin.' }),
|
|
75
78
|
model: Type.String({ description: 'Exact provider-local model id to pin.' }),
|
|
76
79
|
},
|
|
77
|
-
{
|
|
80
|
+
{
|
|
81
|
+
additionalProperties: false,
|
|
82
|
+
description: 'Explicit route. Defaults to the current model.',
|
|
83
|
+
},
|
|
78
84
|
),
|
|
79
85
|
),
|
|
80
86
|
capability: Type.Optional(
|
|
@@ -83,7 +89,9 @@ const DelegateParams = Type.Object(
|
|
|
83
89
|
}),
|
|
84
90
|
),
|
|
85
91
|
maxTurns: Type.Optional(
|
|
86
|
-
Type.Number({
|
|
92
|
+
Type.Number({
|
|
93
|
+
description: `Maximum agent turns. Default ${String(DELEGATE_DEFAULT_MAX_TURNS)}.`,
|
|
94
|
+
}),
|
|
87
95
|
),
|
|
88
96
|
maxToolCalls: Type.Optional(
|
|
89
97
|
Type.Number({
|
|
@@ -113,7 +121,9 @@ const DelegateParams = Type.Object(
|
|
|
113
121
|
|
|
114
122
|
const ResultParams = Type.Object(
|
|
115
123
|
{
|
|
116
|
-
taskId: Type.String({
|
|
124
|
+
taskId: Type.String({
|
|
125
|
+
description: 'Background delegate or Fusion task id returned by its launch tool.',
|
|
126
|
+
}),
|
|
117
127
|
delivery: Type.Optional(
|
|
118
128
|
Type.String({
|
|
119
129
|
description:
|
|
@@ -141,6 +151,20 @@ export interface DelegateLaunchDetails {
|
|
|
141
151
|
trigger_on_completion: boolean;
|
|
142
152
|
}
|
|
143
153
|
|
|
154
|
+
export interface FusionBackgroundResultDetails {
|
|
155
|
+
schema_version: 'pi-background-tasks.fusion-result-view.v1';
|
|
156
|
+
task_id: string;
|
|
157
|
+
state: 'running' | 'committed' | 'failed' | 'cancelled';
|
|
158
|
+
delivery: DelegateDeliveryMode | 'none';
|
|
159
|
+
workflow: FusionWorkflowId;
|
|
160
|
+
artifact_dir: string;
|
|
161
|
+
answer_bytes?: number | undefined;
|
|
162
|
+
answer_sha256?: string | undefined;
|
|
163
|
+
usage_delivered?: boolean | undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export type BackgroundResultDetails = DelegateResultDetails | FusionBackgroundResultDetails;
|
|
167
|
+
|
|
144
168
|
export interface DelegateResultDetails {
|
|
145
169
|
schema_version: 'pi-background-tasks.delegate-result-view.v1';
|
|
146
170
|
task_id: string;
|
|
@@ -217,7 +241,12 @@ function requireRoute(value: unknown): DelegateRoute | undefined {
|
|
|
217
241
|
|
|
218
242
|
function optionalPositiveInteger(value: unknown, label: string): number | undefined {
|
|
219
243
|
if (value === undefined) return undefined;
|
|
220
|
-
if (
|
|
244
|
+
if (
|
|
245
|
+
typeof value !== 'number' ||
|
|
246
|
+
!Number.isFinite(value) ||
|
|
247
|
+
!Number.isInteger(value) ||
|
|
248
|
+
value <= 0
|
|
249
|
+
)
|
|
221
250
|
throw new DelegateError(`bg_delegate ${label} must be a positive integer`, {
|
|
222
251
|
code: 'invalid_arguments',
|
|
223
252
|
childCreated: false,
|
|
@@ -226,12 +255,10 @@ function optionalPositiveInteger(value: unknown, label: string): number | undefi
|
|
|
226
255
|
}
|
|
227
256
|
|
|
228
257
|
export interface DelegateExtensionDependencies {
|
|
229
|
-
startDelegateTask: (
|
|
230
|
-
ctx: ExtensionContext,
|
|
231
|
-
options: StartDelegateTaskOptions,
|
|
232
|
-
) => Promise<BgTask>;
|
|
258
|
+
startDelegateTask: (ctx: ExtensionContext, options: StartDelegateTaskOptions) => Promise<BgTask>;
|
|
233
259
|
snapshot: (task: BgTask) => BgTaskSnapshot;
|
|
234
260
|
resolveTask: (idOrPrefix: string) => BgTask;
|
|
261
|
+
claimFusionUsage: (task: BgTask) => Promise<boolean>;
|
|
235
262
|
/** Overridable so tests can supply observed evidence without touching disk. */
|
|
236
263
|
loadHookEvidence?: (() => Promise<DelegateHookContractEvidence>) | undefined;
|
|
237
264
|
}
|
|
@@ -326,13 +353,11 @@ export function registerDelegateExtension(
|
|
|
326
353
|
id: ctx.model.id,
|
|
327
354
|
contextWindow: ctx.model.contextWindow,
|
|
328
355
|
},
|
|
329
|
-
availableModels: ctx.modelRegistry
|
|
330
|
-
.
|
|
331
|
-
.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
contextWindow: model.contextWindow,
|
|
335
|
-
})),
|
|
356
|
+
availableModels: ctx.modelRegistry.getAll().map((model) => ({
|
|
357
|
+
provider: model.provider,
|
|
358
|
+
id: model.id,
|
|
359
|
+
contextWindow: model.contextWindow,
|
|
360
|
+
})),
|
|
336
361
|
thinkingLevel: pi.getThinkingLevel(),
|
|
337
362
|
});
|
|
338
363
|
|
|
@@ -425,15 +450,15 @@ export function registerDelegateExtension(
|
|
|
425
450
|
},
|
|
426
451
|
});
|
|
427
452
|
|
|
428
|
-
pi.registerTool<typeof ResultParams,
|
|
453
|
+
pi.registerTool<typeof ResultParams, BackgroundResultDetails>({
|
|
429
454
|
name: DELEGATE_RESULT_TOOL_NAME,
|
|
430
|
-
label: '
|
|
455
|
+
label: 'Background Result',
|
|
431
456
|
description:
|
|
432
|
-
'Retrieve
|
|
433
|
-
promptSnippet: 'Retrieve the verified answer from a completed
|
|
457
|
+
'Retrieve a hash-verified result from a bg_delegate or background Fusion task. Never blocks: a running task returns a typed not-ready result. Oversized answers are never truncated.',
|
|
458
|
+
promptSnippet: 'Retrieve the verified answer from a completed delegate or Fusion task',
|
|
434
459
|
promptGuidelines: [
|
|
435
|
-
'Call bg_result once the delegate terminal notification has arrived. It never blocks and must not be polled.',
|
|
436
|
-
'A not-ready result means the
|
|
460
|
+
'Call bg_result once the delegate or Fusion terminal notification has arrived. It never blocks and must not be polled.',
|
|
461
|
+
'A not-ready result means the task is still running; end the turn and wait for the notification.',
|
|
437
462
|
],
|
|
438
463
|
parameters: ResultParams,
|
|
439
464
|
prepareArguments(args): ResultParamsValue {
|
|
@@ -463,10 +488,86 @@ export function registerDelegateExtension(
|
|
|
463
488
|
{ code: 'task_unknown', childCreated: false },
|
|
464
489
|
);
|
|
465
490
|
}
|
|
491
|
+
const fusion = task.fusion;
|
|
492
|
+
if (fusion !== undefined) {
|
|
493
|
+
const requestedDelivery = requireDelivery(params.delivery);
|
|
494
|
+
if (task.status === 'running') {
|
|
495
|
+
const details: FusionBackgroundResultDetails = {
|
|
496
|
+
schema_version: 'pi-background-tasks.fusion-result-view.v1',
|
|
497
|
+
task_id: task.id,
|
|
498
|
+
state: 'running',
|
|
499
|
+
delivery: 'none',
|
|
500
|
+
workflow: fusion.workflow,
|
|
501
|
+
artifact_dir: fusion.artifactDir,
|
|
502
|
+
};
|
|
503
|
+
return {
|
|
504
|
+
content: textContent(
|
|
505
|
+
`Fusion ${task.id} is still running. bg_result never blocks. End this turn; the terminal notification will wake you, then call bg_result again.`,
|
|
506
|
+
),
|
|
507
|
+
details,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
if (task.status !== 'completed' || fusion.outcome?.status !== 'committed') {
|
|
511
|
+
throw new Error(
|
|
512
|
+
`Fusion ${task.id} did not commit a result (${task.status}): ${fusion.outcome?.error ?? task.error ?? 'no terminal detail'}`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
const verified = await readFusionCommittedResult({
|
|
516
|
+
artifactDirAbs: fusion.artifactDirAbs,
|
|
517
|
+
artifactDir: fusion.artifactDir,
|
|
518
|
+
runId: fusion.runId,
|
|
519
|
+
workflow: fusion.workflow,
|
|
520
|
+
});
|
|
521
|
+
const answerBytes = Buffer.byteLength(verified.mergedText, 'utf8');
|
|
522
|
+
const answerSha256 = sha256Buffer(Buffer.from(verified.mergedText, 'utf8'));
|
|
523
|
+
const useArtifact =
|
|
524
|
+
requestedDelivery === 'artifact' ||
|
|
525
|
+
(requestedDelivery === undefined && answerBytes > DELEGATE_INLINE_ANSWER_BYTES);
|
|
526
|
+
if (requestedDelivery === 'inline' && answerBytes > DELEGATE_INLINE_ANSWER_BYTES) {
|
|
527
|
+
throw new Error(
|
|
528
|
+
`Fusion result ${task.id} is ${String(answerBytes)} bytes, above the ${String(DELEGATE_INLINE_ANSWER_BYTES)}-byte inline limit. Use delivery:"artifact"; nothing was truncated.`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
const usageDelivered = await deps.claimFusionUsage(task);
|
|
532
|
+
const details: FusionBackgroundResultDetails = {
|
|
533
|
+
schema_version: 'pi-background-tasks.fusion-result-view.v1',
|
|
534
|
+
task_id: task.id,
|
|
535
|
+
state: 'committed',
|
|
536
|
+
delivery: useArtifact ? 'artifact' : 'inline',
|
|
537
|
+
workflow: fusion.workflow,
|
|
538
|
+
artifact_dir: fusion.artifactDir,
|
|
539
|
+
answer_bytes: answerBytes,
|
|
540
|
+
answer_sha256: answerSha256,
|
|
541
|
+
usage_delivered: usageDelivered,
|
|
542
|
+
};
|
|
543
|
+
const header = [
|
|
544
|
+
`Fusion ${task.id} completed (${fusion.workflow}).`,
|
|
545
|
+
`Answer: ${String(answerBytes)} bytes, ${answerSha256} (verified).`,
|
|
546
|
+
`Artifacts: ${fusion.artifactDir}`,
|
|
547
|
+
usageDelivered
|
|
548
|
+
? 'Usage: attached to this retrieval exactly once.'
|
|
549
|
+
: 'Usage: already attached by an earlier retrieval; not counted again.',
|
|
550
|
+
].join('\n');
|
|
551
|
+
const result = useArtifact
|
|
552
|
+
? {
|
|
553
|
+
content: textContent(
|
|
554
|
+
`${header}\nDelivery: artifact. The complete answer is ${fusion.artifactDir}/merged.md; it was not truncated.`,
|
|
555
|
+
),
|
|
556
|
+
details,
|
|
557
|
+
}
|
|
558
|
+
: { content: textContent(`${header}\n\n${verified.mergedText}`), details };
|
|
559
|
+
if (!usageDelivered) return result;
|
|
560
|
+
const resultWithUsage: typeof result & { usage: FusionUsage } = {
|
|
561
|
+
...result,
|
|
562
|
+
usage: cloneFusionUsage(verified.details.usage),
|
|
563
|
+
};
|
|
564
|
+
return resultWithUsage;
|
|
565
|
+
}
|
|
566
|
+
|
|
466
567
|
const facts = task.delegate;
|
|
467
568
|
if (facts === undefined) {
|
|
468
569
|
throw new DelegateError(
|
|
469
|
-
`bg_result task ${task.id}
|
|
570
|
+
`bg_result task ${task.id} has no retrievable delegate or Fusion result; use bg_logs for ordinary background tasks`,
|
|
470
571
|
{ code: 'task_unknown', childCreated: false },
|
|
471
572
|
);
|
|
472
573
|
}
|
|
@@ -565,10 +666,15 @@ export function registerDelegateExtension(
|
|
|
565
666
|
renderResult(result, options: ToolRenderResultOptions, theme: Theme) {
|
|
566
667
|
void options;
|
|
567
668
|
const details = result.details;
|
|
669
|
+
const fusion = details.schema_version === 'pi-background-tasks.fusion-result-view.v1';
|
|
568
670
|
if (details.state === 'running')
|
|
569
|
-
return new Text(
|
|
671
|
+
return new Text(
|
|
672
|
+
theme.fg('warning', `${fusion ? 'fusion' : 'delegate'} ${details.task_id} still running`),
|
|
673
|
+
0,
|
|
674
|
+
0,
|
|
675
|
+
);
|
|
570
676
|
return new Text(
|
|
571
|
-
`${theme.fg('success', '✓ delegate answer')} ${theme.fg('dim', `${String(details.answer_bytes ?? 0)}B · ${details.delivery}`)}`,
|
|
677
|
+
`${theme.fg('success', fusion ? '✓ fusion answer' : '✓ delegate answer')} ${theme.fg('dim', `${String(details.answer_bytes ?? 0)}B · ${details.delivery}`)}`,
|
|
572
678
|
0,
|
|
573
679
|
0,
|
|
574
680
|
);
|
package/src/extension.ts
CHANGED
|
@@ -204,8 +204,6 @@ function renderPlainResult(result: TextToolResult, options: ToolRenderResultOpti
|
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
207
|
-
registerFusionExtension(pi);
|
|
208
|
-
|
|
209
207
|
const seenTaskIds = new Set<string>();
|
|
210
208
|
let currentCtx: ExtensionContext | undefined;
|
|
211
209
|
let dockOpen = false;
|
|
@@ -213,7 +211,6 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
213
211
|
let latestKnownVersion: string | undefined;
|
|
214
212
|
let updateCheckStarted = false;
|
|
215
213
|
|
|
216
|
-
let eventService: BackgroundTaskExtensionService | undefined;
|
|
217
214
|
const registry = new BackgroundTaskRegistry({
|
|
218
215
|
onChange: () => {
|
|
219
216
|
updateUi();
|
|
@@ -222,17 +219,25 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
222
219
|
pi.sendMessage(message, options);
|
|
223
220
|
},
|
|
224
221
|
publishTerminal: (task) => {
|
|
225
|
-
if (!eventService) throw new Error('Background task EventBus service is not installed');
|
|
226
222
|
eventService.publishTerminal(task);
|
|
227
223
|
},
|
|
228
224
|
});
|
|
229
|
-
eventService = installBackgroundTaskExtensionApi({
|
|
225
|
+
const eventService: BackgroundTaskExtensionService = installBackgroundTaskExtensionApi({
|
|
230
226
|
events: pi.events,
|
|
231
227
|
registry,
|
|
232
228
|
getContext: () => currentCtx,
|
|
233
229
|
isShuttingDown: () => registry.isShuttingDown(),
|
|
234
230
|
});
|
|
235
231
|
|
|
232
|
+
registerFusionExtension(pi, {
|
|
233
|
+
startManagedTask: async (ctx, options) => {
|
|
234
|
+
currentCtx = ctx;
|
|
235
|
+
return registry.startManagedTask(ctx, options);
|
|
236
|
+
},
|
|
237
|
+
snapshot: (task) => registry.snapshot(task),
|
|
238
|
+
updateManagedTask: (task, state, line) => registry.updateManagedTask(task, state, line),
|
|
239
|
+
});
|
|
240
|
+
|
|
236
241
|
registerDelegateExtension(pi, {
|
|
237
242
|
startDelegateTask: async (ctx, options) => {
|
|
238
243
|
currentCtx = ctx;
|
|
@@ -240,6 +245,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
240
245
|
},
|
|
241
246
|
snapshot: (task) => registry.snapshot(task),
|
|
242
247
|
resolveTask: (idOrPrefix) => registry.resolveTask(idOrPrefix),
|
|
248
|
+
claimFusionUsage: (task) => registry.claimFusionUsage(task),
|
|
243
249
|
});
|
|
244
250
|
|
|
245
251
|
function unseenFinishedTasks(): BgTask[] {
|
|
@@ -359,6 +365,11 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
359
365
|
return result;
|
|
360
366
|
},
|
|
361
367
|
rerunTask: async (task: BackgroundTaskForUi) => {
|
|
368
|
+
if (task.fusion !== undefined || task.delegate !== undefined) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
'Only shell-command tasks can be rerun from the dock; relaunch this typed workflow through its owning tool.',
|
|
371
|
+
);
|
|
372
|
+
}
|
|
362
373
|
const rerunOptions: StartTaskOptions = {
|
|
363
374
|
name: taskDisplayName(task),
|
|
364
375
|
isAgent: task.isAgent,
|
|
@@ -499,7 +510,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
499
510
|
ctx.ui.notify(`Background task cleanup failed:\n${failures.join('\n')}`, 'error');
|
|
500
511
|
}
|
|
501
512
|
} finally {
|
|
502
|
-
eventService
|
|
513
|
+
eventService.close();
|
|
503
514
|
}
|
|
504
515
|
});
|
|
505
516
|
|