pi-long-task 0.4.0 → 0.5.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.
@@ -116,6 +116,18 @@ Only use \`status: done\` if the assigned task is fully complete and verified as
116
116
 
117
117
  export const buildAssignedTaskPrompt = buildTaskPrompt;
118
118
 
119
+ export function buildReusedAssignmentPrompt(
120
+ options: WorkerTaskPromptOptions,
121
+ previousTask: Pick<Task, "taskId" | "title">,
122
+ ): string {
123
+ return `Pi Long Task assignment boundary:
124
+ The prior assignment ${taskLabel(previousTask)} has ended.
125
+ A new, independent assignment has begun: ${taskLabel(options.task)}.
126
+ Treat every instruction and TASK_RESULT below as belonging only to this new assignment. Do not repeat or reuse the prior assignment's TASK_RESULT.
127
+
128
+ ${buildTaskPrompt(options)}`;
129
+ }
130
+
119
131
  export function buildTimeLimitMessage(seconds: number): string {
120
132
  return `Pi Long Task notice: this worker session has reached its ${seconds.toFixed(0)}s time budget.
121
133
  Stop after the current safe point. Do not start more implementation work.
@@ -244,6 +256,18 @@ export interface WorkerSessionFactoryResult {
244
256
  diagnostics?: string[];
245
257
  }
246
258
 
259
+ /** Coordinator-owned session allocation. Disposal is idempotent and owned by disposeWorkerSessionResource(). */
260
+ export interface WorkerSessionResource extends WorkerSessionFactoryResult {
261
+ disposed: boolean;
262
+ /** Last readable cumulative statistics, retained only for task-boundary delta accounting. */
263
+ accountingBaseline?: WorkerSessionStatsSnapshot;
264
+ completedAssignments?: number;
265
+ }
266
+
267
+ export interface ReusedWorkerAssignment {
268
+ previousTask: Pick<Task, "taskId" | "title">;
269
+ }
270
+
247
271
  export interface CreateWorkerSessionOptions {
248
272
  cwd: string;
249
273
  agentDir?: string;
@@ -268,6 +292,7 @@ export interface RunWorkerTaskOptions extends WorkerTaskPromptOptions, CreateWor
268
292
  abortSignal?: AbortSignal;
269
293
  sessionFactory?: WorkerSessionFactory;
270
294
  onEvent?: (event: CapturedWorkerEvent) => void;
295
+ onSessionDiagnostic?: (diagnostic: WorkerSessionDiagnostic) => void;
271
296
  now?: () => Date;
272
297
  }
273
298
 
@@ -282,6 +307,29 @@ export interface CapturedWorkerEvent {
282
307
  usageCostKey?: string;
283
308
  }
284
309
 
310
+ export interface WorkerUsageTotals {
311
+ input: number;
312
+ output: number;
313
+ cacheRead: number;
314
+ cacheWrite: number;
315
+ total: number;
316
+ }
317
+
318
+ export interface WorkerSessionStatsSnapshot {
319
+ cost?: number;
320
+ tokens?: WorkerUsageTotals;
321
+ }
322
+
323
+ export type WorkerSessionLifecycleEvent = "session_started" | "session_reused" | "session_rotated" | "session_retained";
324
+
325
+ export interface WorkerSessionDiagnostic {
326
+ event: WorkerSessionLifecycleEvent;
327
+ reasonCode: string;
328
+ contextUsagePercent?: number;
329
+ contextThresholdPercent?: number;
330
+ previousTaskId?: string;
331
+ }
332
+
285
333
  export interface SessionOutcome {
286
334
  task: Pick<Task, "taskId" | "title" | "section">;
287
335
  attempt: number;
@@ -297,6 +345,10 @@ export interface SessionOutcome {
297
345
  events: CapturedWorkerEvent[];
298
346
  workerCostTotal: number;
299
347
  workerCostSource?: string;
348
+ /** Task/attempt-scoped token deltas when cumulative session statistics are available. */
349
+ workerUsage?: WorkerUsageTotals;
350
+ /** Additive lifecycle evidence; omitted by legacy/custom worker runners. */
351
+ sessionDiagnostics?: WorkerSessionDiagnostic[];
300
352
  shutdownRequested: boolean;
301
353
  timedOut: boolean;
302
354
  aborted: boolean;
@@ -424,7 +476,29 @@ export async function createIsolatedWorkerSession(
424
476
  };
425
477
  }
426
478
 
427
- export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<SessionOutcome> {
479
+ export async function createWorkerSessionResource(
480
+ options: CreateWorkerSessionOptions,
481
+ sessionFactory: WorkerSessionFactory = createIsolatedWorkerSession,
482
+ ): Promise<WorkerSessionResource> {
483
+ const result = await sessionFactory(options);
484
+ return { ...result, disposed: false, completedAssignments: 0 };
485
+ }
486
+
487
+ /** Dispose an allocated worker session at most once, regardless of competing ownership paths. */
488
+ export async function disposeWorkerSessionResource(resource: WorkerSessionResource): Promise<void> {
489
+ if (resource.disposed) {
490
+ return;
491
+ }
492
+ resource.disposed = true;
493
+ await Promise.resolve(resource.session.dispose?.());
494
+ }
495
+
496
+ /** Execute exactly one assignment in an already-created session without disposing that session. */
497
+ export async function runWorkerTaskAssignment(
498
+ options: RunWorkerTaskOptions,
499
+ resource: WorkerSessionResource,
500
+ reusedAssignment?: ReusedWorkerAssignment,
501
+ ): Promise<SessionOutcome> {
428
502
  const now = options.now ?? (() => new Date());
429
503
  const startedAt = now().toISOString();
430
504
  const contextObservations: string[] = [];
@@ -442,14 +516,19 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
442
516
  let turnCount = 0;
443
517
  let messageUsageCostTotal = 0;
444
518
  let hasMessageUsageCost = false;
445
- let sessionStatsCostTotal: number | undefined;
519
+ let sessionStatsStart: WorkerSessionStatsSnapshot | undefined;
520
+ let sessionStatsEnd: WorkerSessionStatsSnapshot | undefined;
521
+ let accountingBaseline: WorkerSessionStatsSnapshot | undefined;
522
+ const wasFirstAssignment = (resource.completedAssignments ?? 0) === 0;
446
523
  let resolvePromptWait: (() => void) | undefined;
447
524
 
448
- const prompt = buildTaskPrompt(options);
525
+ const prompt = reusedAssignment
526
+ ? buildReusedAssignmentPrompt(options, reusedAssignment.previousTask)
527
+ : buildTaskPrompt(options);
449
528
  const taskTimeoutSeconds = options.taskTimeoutSeconds ?? DEFAULT_TASK_TIMEOUT_SECONDS;
450
529
  const gracefulShutdownSeconds = options.gracefulShutdownSeconds ?? DEFAULT_GRACEFUL_SHUTDOWN_SECONDS;
451
- const sessionFactory = options.sessionFactory ?? createIsolatedWorkerSession;
452
- let session: WorkerSessionLike | undefined;
530
+ const session = resource.session;
531
+ const invocationMessageStart = Array.isArray(session.messages) ? session.messages.length : 0;
453
532
  let unsubscribe: (() => void) | undefined;
454
533
  const timers = new Set<ReturnType<typeof setTimeout>>();
455
534
 
@@ -578,14 +657,14 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
578
657
  throw new Error("worker session aborted before start");
579
658
  }
580
659
 
581
- const factoryResult = await sessionFactory(options);
582
- session = factoryResult.session;
660
+ sessionStatsStart = await workerSessionStatsSnapshot(session);
661
+ accountingBaseline = sessionStatsStart ?? resource.accountingBaseline;
583
662
  sessionFile = session.sessionFile;
584
663
  sessionId = session.sessionId;
585
- if (factoryResult.modelFallbackMessage) {
586
- contextObservations.push(`model fallback: ${factoryResult.modelFallbackMessage}`);
664
+ if (resource.modelFallbackMessage) {
665
+ contextObservations.push(`model fallback: ${resource.modelFallbackMessage}`);
587
666
  }
588
- for (const diagnostic of factoryResult.diagnostics ?? []) {
667
+ for (const diagnostic of resource.diagnostics ?? []) {
589
668
  contextObservations.push(diagnostic);
590
669
  }
591
670
 
@@ -684,14 +763,14 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
684
763
  }
685
764
 
686
765
  await waitForPrompt(prompt);
687
- assistantText = latestAssistantText(session, assistantText);
766
+ assistantText = latestInvocationAssistantText(session, assistantText, invocationMessageStart, !reusedAssignment);
688
767
 
689
768
  if (!hasCompleteTaskResult(assistantText) && !error && !aborted && !timedOut && !options.abortSignal?.aborted) {
690
769
  contextObservations.push(
691
770
  "missing TASK_RESULT status after initial prompt, or required fields were incomplete; requested required block once",
692
771
  );
693
772
  await waitForPrompt(buildMissingTaskResultMessage());
694
- assistantText = latestAssistantText(session, assistantText);
773
+ assistantText = latestInvocationAssistantText(session, assistantText, invocationMessageStart, !reusedAssignment);
695
774
  }
696
775
  } catch (exc) {
697
776
  error = error ?? errorMessage(exc);
@@ -700,17 +779,16 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
700
779
  clearTimers();
701
780
  options.abortSignal?.removeEventListener("abort", abortListener);
702
781
  unsubscribe?.();
703
- if (session) {
704
- assistantText = latestAssistantText(session, assistantText);
705
- sessionFile = session.sessionFile ?? sessionFile;
706
- sessionId = session.sessionId ?? sessionId;
707
- sessionStatsCostTotal = await workerUsageCostFromSessionStats(session);
708
- try {
709
- await Promise.resolve(session.dispose?.());
710
- } catch (exc) {
711
- compactionEvents.push(`session dispose failed: ${errorMessage(exc)}`);
712
- }
782
+ assistantText = latestInvocationAssistantText(session, assistantText, invocationMessageStart, !reusedAssignment);
783
+ sessionFile = session.sessionFile ?? sessionFile;
784
+ sessionId = session.sessionId ?? sessionId;
785
+ sessionStatsEnd = await workerSessionStatsSnapshot(session);
786
+ if (sessionStatsEnd) {
787
+ resource.accountingBaseline = sessionStatsEnd;
788
+ } else if (sessionStatsStart) {
789
+ resource.accountingBaseline = sessionStatsStart;
713
790
  }
791
+ resource.completedAssignments = (resource.completedAssignments ?? 0) + 1;
714
792
  }
715
793
 
716
794
  if ((error || aborted || timedOut) && !hasTaskResult(assistantText)) {
@@ -719,9 +797,11 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
719
797
 
720
798
  const parsedResult = parseCompleteTaskResult(assistantText);
721
799
  const reportedStatus = parsedResult?.status ?? parseReportedStatus(assistantText);
800
+ const cancelled = Boolean(options.abortSignal?.aborted);
801
+ const statsDelta = workerSessionStatsDelta(accountingBaseline, sessionStatsEnd, wasFirstAssignment);
722
802
  const capturedWorkerCost = selectWorkerCostTotal({
723
803
  messageCostTotal: hasMessageUsageCost ? messageUsageCostTotal : undefined,
724
- statsCostTotal: sessionStatsCostTotal,
804
+ statsCostTotal: statsDelta?.cost,
725
805
  });
726
806
  return {
727
807
  task: options.task,
@@ -729,7 +809,7 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
729
809
  startedAt,
730
810
  endedAt: now().toISOString(),
731
811
  reportedStatus,
732
- done: Boolean(parsedResult && isDoneStatus(reportedStatus) && !error && !aborted && !timedOut),
812
+ done: Boolean(parsedResult && isDoneStatus(reportedStatus) && !error && !aborted && !timedOut && !cancelled),
733
813
  assistantText,
734
814
  sessionFile,
735
815
  sessionId,
@@ -738,13 +818,63 @@ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<Sess
738
818
  events,
739
819
  workerCostTotal: capturedWorkerCost.total,
740
820
  workerCostSource: capturedWorkerCost.source,
821
+ workerUsage: statsDelta?.tokens,
741
822
  shutdownRequested,
742
823
  timedOut,
743
- aborted: aborted || Boolean(options.abortSignal?.aborted),
824
+ aborted: aborted || cancelled,
744
825
  error,
745
826
  };
746
827
  }
747
828
 
829
+ /** Backward-compatible isolated lifecycle: create, execute one assignment, and dispose. */
830
+ export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<SessionOutcome> {
831
+ let resource: WorkerSessionResource | undefined;
832
+ try {
833
+ resource = await createWorkerSessionResource(options, options.sessionFactory ?? createIsolatedWorkerSession);
834
+ return await runWorkerTaskAssignment(options, resource);
835
+ } catch (error) {
836
+ if (resource) {
837
+ throw error;
838
+ }
839
+ return buildWorkerSessionCreationFailureOutcome(options, error);
840
+ } finally {
841
+ if (resource) {
842
+ try {
843
+ await disposeWorkerSessionResource(resource);
844
+ } catch {
845
+ // Preserve the historical best-effort worker disposal behavior.
846
+ }
847
+ }
848
+ }
849
+ }
850
+
851
+ export function buildWorkerSessionCreationFailureOutcome(
852
+ options: RunWorkerTaskOptions,
853
+ error: unknown,
854
+ ): SessionOutcome {
855
+ const now = options.now ?? (() => new Date());
856
+ const startedAt = now().toISOString();
857
+ const message = errorMessage(error);
858
+ const assistantText = buildLongTaskFailureTaskResult(message);
859
+ return {
860
+ task: options.task,
861
+ attempt: options.attempt,
862
+ startedAt,
863
+ endedAt: now().toISOString(),
864
+ reportedStatus: parseReportedStatus(assistantText),
865
+ done: false,
866
+ assistantText,
867
+ contextObservations: [],
868
+ compactionEvents: [],
869
+ events: [],
870
+ workerCostTotal: 0,
871
+ shutdownRequested: false,
872
+ timedOut: false,
873
+ aborted: Boolean(options.abortSignal?.aborted),
874
+ error: message,
875
+ };
876
+ }
877
+
748
878
  function textFromContentPart(item: unknown): string {
749
879
  if (!isRecord(item)) {
750
880
  return "";
@@ -1015,18 +1145,92 @@ export function workerUsageCostFromStats(stats: unknown): number | undefined {
1015
1145
  return usageCostTotal(stats.usage) ?? usageCostTotal(stats);
1016
1146
  }
1017
1147
 
1018
- async function workerUsageCostFromSessionStats(session: WorkerSessionLike): Promise<number | undefined> {
1148
+ async function workerSessionStatsSnapshot(session: WorkerSessionLike): Promise<WorkerSessionStatsSnapshot | undefined> {
1019
1149
  if (!session.getSessionStats) {
1020
1150
  return undefined;
1021
1151
  }
1022
1152
 
1023
1153
  try {
1024
- return workerUsageCostFromStats(await session.getSessionStats());
1154
+ const stats = await session.getSessionStats();
1155
+ const cost = workerUsageCostFromStats(stats);
1156
+ const tokens = workerUsageTokensFromStats(stats);
1157
+ return cost === undefined && !tokens ? undefined : { cost, tokens };
1025
1158
  } catch {
1026
1159
  return undefined;
1027
1160
  }
1028
1161
  }
1029
1162
 
1163
+ /**
1164
+ * Convert cumulative session counters into one assignment's nonnegative delta.
1165
+ * A lower ending counter means the SDK reset that counter, so the ending value
1166
+ * is the entire post-reset contribution. Without any baseline, cumulative
1167
+ * values are safe only for the resource's first assignment.
1168
+ */
1169
+ export function workerSessionStatsDelta(
1170
+ baseline: WorkerSessionStatsSnapshot | undefined,
1171
+ ending: WorkerSessionStatsSnapshot | undefined,
1172
+ firstAssignment: boolean,
1173
+ ): WorkerSessionStatsSnapshot | undefined {
1174
+ if (!ending) {
1175
+ return undefined;
1176
+ }
1177
+ if (!baseline && !firstAssignment) {
1178
+ return undefined;
1179
+ }
1180
+
1181
+ const cost = cumulativeCounterDelta(baseline?.cost, ending.cost, firstAssignment);
1182
+ const tokens = ending.tokens
1183
+ ? {
1184
+ input: cumulativeCounterDelta(baseline?.tokens?.input, ending.tokens.input, firstAssignment) ?? 0,
1185
+ output: cumulativeCounterDelta(baseline?.tokens?.output, ending.tokens.output, firstAssignment) ?? 0,
1186
+ cacheRead: cumulativeCounterDelta(baseline?.tokens?.cacheRead, ending.tokens.cacheRead, firstAssignment) ?? 0,
1187
+ cacheWrite:
1188
+ cumulativeCounterDelta(baseline?.tokens?.cacheWrite, ending.tokens.cacheWrite, firstAssignment) ?? 0,
1189
+ total: cumulativeCounterDelta(baseline?.tokens?.total, ending.tokens.total, firstAssignment) ?? 0,
1190
+ }
1191
+ : undefined;
1192
+ return cost === undefined && !tokens ? undefined : { cost, tokens };
1193
+ }
1194
+
1195
+ function workerUsageTokensFromStats(stats: unknown): WorkerUsageTotals | undefined {
1196
+ if (!isRecord(stats)) {
1197
+ return undefined;
1198
+ }
1199
+ const tokens = isRecord(stats.tokens) ? stats.tokens : isRecord(stats.usage) ? stats.usage : undefined;
1200
+ if (!tokens) {
1201
+ return undefined;
1202
+ }
1203
+ const input = finiteNonNegativeNumber(tokens.input);
1204
+ const output = finiteNonNegativeNumber(tokens.output);
1205
+ const cacheRead = finiteNonNegativeNumber(tokens.cacheRead ?? tokens.cache_read);
1206
+ const cacheWrite = finiteNonNegativeNumber(tokens.cacheWrite ?? tokens.cache_write);
1207
+ const total = finiteNonNegativeNumber(tokens.total);
1208
+ if ([input, output, cacheRead, cacheWrite, total].every((value) => value === undefined)) {
1209
+ return undefined;
1210
+ }
1211
+ return {
1212
+ input: input ?? 0,
1213
+ output: output ?? 0,
1214
+ cacheRead: cacheRead ?? 0,
1215
+ cacheWrite: cacheWrite ?? 0,
1216
+ total: total ?? (input ?? 0) + (output ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0),
1217
+ };
1218
+ }
1219
+
1220
+ function cumulativeCounterDelta(
1221
+ baseline: number | undefined,
1222
+ ending: number | undefined,
1223
+ allowUnbased: boolean,
1224
+ ): number | undefined {
1225
+ if (ending === undefined) {
1226
+ return undefined;
1227
+ }
1228
+ if (baseline === undefined) {
1229
+ return allowUnbased ? ending : undefined;
1230
+ }
1231
+ return ending >= baseline ? ending - baseline : ending;
1232
+ }
1233
+
1030
1234
  function usageCostTotal(usage: unknown): number | undefined {
1031
1235
  if (!isRecord(usage)) {
1032
1236
  return undefined;
@@ -1074,6 +1278,19 @@ function contextUsageFromStats(stats: unknown): unknown {
1074
1278
  return isRecord(stats) ? stats.contextUsage : undefined;
1075
1279
  }
1076
1280
 
1281
+ export async function workerSessionContextUsagePercent(session: WorkerSessionLike): Promise<number | undefined> {
1282
+ try {
1283
+ const direct = session.getContextUsage?.();
1284
+ if (direct !== undefined) {
1285
+ return contextPercent(direct);
1286
+ }
1287
+ const stats = session.getSessionStats ? await session.getSessionStats() : undefined;
1288
+ return contextPercent(contextUsageFromStats(stats));
1289
+ } catch {
1290
+ return undefined;
1291
+ }
1292
+ }
1293
+
1077
1294
  function contextPercent(usage: unknown): number | undefined {
1078
1295
  if (!isRecord(usage)) {
1079
1296
  return undefined;
@@ -1128,13 +1345,24 @@ function formatCompactionEndEvent(event: Record<string, unknown>): string {
1128
1345
  return `compaction_end reason=${reason} aborted=${aborted} error=${String(event.errorMessage ?? "unknown")}`;
1129
1346
  }
1130
1347
 
1131
- function latestAssistantText(session: WorkerSessionLike, fallback: string): string {
1132
- const direct = session.getLastAssistantText?.();
1133
- if (direct) {
1134
- return direct;
1348
+ function latestInvocationAssistantText(
1349
+ session: WorkerSessionLike,
1350
+ fallback: string,
1351
+ messageStart: number,
1352
+ allowDirectFallback: boolean,
1353
+ ): string {
1354
+ const invocationMessages = Array.isArray(session.messages) ? session.messages.slice(messageStart) : undefined;
1355
+ const fromInvocation = lastAssistantTextFromMessages(invocationMessages);
1356
+ if (fromInvocation) {
1357
+ return fromInvocation;
1358
+ }
1359
+ if (allowDirectFallback) {
1360
+ const direct = session.getLastAssistantText?.();
1361
+ if (direct) {
1362
+ return direct;
1363
+ }
1135
1364
  }
1136
- const fromMessages = lastAssistantTextFromMessages(session.messages);
1137
- return fromMessages || fallback;
1365
+ return fallback;
1138
1366
  }
1139
1367
 
1140
1368
  function buildLongTaskFailureTaskResult(reason: string): string {