@bermudi/pi-delegate 0.1.2 → 0.1.4

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/lifecycle.ts CHANGED
@@ -27,6 +27,8 @@ import { resolveCwd, validateResumeFromPath } from "./utils.ts";
27
27
  import { getWholeTaskMaxRetries, getWholeTaskBaseDelayMs } from "./config.ts";
28
28
  import { addUsage, emptyUsage } from "./usage.ts";
29
29
  import { scheduleDeadline } from "./timer.ts";
30
+ import { recordTask } from "./telemetry.ts";
31
+ import { createScratchWorkspace, ScratchDeadlineError } from "./workspace.ts";
30
32
 
31
33
  /** Internal seam for lifecycle-level tests without replacing session ownership. */
32
34
  type RunAgentSession = typeof runAgentSession;
@@ -180,6 +182,11 @@ function updateProgressFromResult(p: TaskProgress, r: TaskResult): void {
180
182
  p.failureKind = r.failureKind;
181
183
  }
182
184
 
185
+ const taskRetryCounts = new WeakMap<TaskResult, number>();
186
+ /** The SQLite task id is part of the logical task, not the transient result
187
+ * object. Scratch wrapping clones results, so keep this alongside retry data. */
188
+ const taskTelemetryIds = new WeakMap<TaskResult, string>();
189
+
183
190
  /** Apply a TaskResult to progress and notify the env (sync fires onUpdate).
184
191
  * Used at every return point in runResolvedTask — mirrors the old fire() pattern
185
192
  * that the duplicated sync/async bodies used after every early-return. */
@@ -187,8 +194,24 @@ function finishTask(
187
194
  env: TaskRunEnv,
188
195
  p: TaskProgress,
189
196
  r: TaskResult,
197
+ task: ResolvedTask,
198
+ retries = 0,
190
199
  ): TaskResult {
191
200
  updateProgressFromResult(p, r);
201
+ taskRetryCounts.set(r, retries);
202
+ if (env.telemetryCallId) {
203
+ const id = recordTask({
204
+ callId: env.telemetryCallId,
205
+ generation: env.telemetryGeneration,
206
+ async: env.async ?? false,
207
+ taskIndex: p.index,
208
+ task,
209
+ progress: p,
210
+ result: r,
211
+ retries,
212
+ });
213
+ if (id) taskTelemetryIds.set(r, id);
214
+ }
192
215
  env.onStatusChange?.();
193
216
  return r;
194
217
  }
@@ -479,15 +502,24 @@ async function acquireAgentSession(
479
502
  }
480
503
 
481
504
  // ── Fresh session (no resume) ────────────────────────────────────────────
482
- const fresh = createSubagentSessionManager(
483
- env.parentSessionManager,
484
- task.cwd,
485
- );
486
- if (!fresh) {
487
- return { error: failTask(task, "Internal: could not create session file") };
505
+ if (task.workspace === "scratch") {
506
+ // A discarded filesystem must not advertise a resumable conversation: a
507
+ // later resume would run against the source cwd and silently lose scratch
508
+ // isolation. Keep scratch transcripts in memory only.
509
+ sessionManager = SessionManager.inMemory(task.cwd);
510
+ } else {
511
+ const fresh = createSubagentSessionManager(
512
+ env.parentSessionManager,
513
+ task.cwd,
514
+ );
515
+ if (!fresh) {
516
+ return {
517
+ error: failTask(task, "Internal: could not create session file"),
518
+ };
519
+ }
520
+ sessionManager = fresh.manager;
521
+ sessionFile = fresh.file;
488
522
  }
489
- sessionManager = fresh.manager;
490
- sessionFile = fresh.file;
491
523
 
492
524
  const session = await buildDelegateSession(
493
525
  task,
@@ -549,11 +581,171 @@ async function runResolvedTaskUnlocked(
549
581
  task: ResolvedTask,
550
582
  p: TaskProgress,
551
583
  taskIndex: number,
584
+ ): Promise<TaskResult> {
585
+ if (task.workspace !== "scratch") {
586
+ return runResolvedTaskCore(env, task, p, taskIndex);
587
+ }
588
+ if (task.sessionId || task.resumeFrom || task.sessionAction) {
589
+ return finishTask(
590
+ env,
591
+ p,
592
+ failTask(
593
+ task,
594
+ "workspace 'scratch' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction.",
595
+ ),
596
+ task,
597
+ );
598
+ }
599
+
600
+ const startedAt = Date.now();
601
+ const deadlineAt =
602
+ task.deadlineMs && task.deadlineMs > 0
603
+ ? startedAt + task.deadlineMs
604
+ : undefined;
605
+ let workspace: Awaited<ReturnType<typeof createScratchWorkspace>>;
606
+ try {
607
+ workspace = await createScratchWorkspace(task.cwd, env.signal, deadlineAt);
608
+ } catch (error) {
609
+ const setupError = error instanceof Error ? error.message : String(error);
610
+ const deadlineExceeded =
611
+ !env.signal?.aborted && error instanceof ScratchDeadlineError;
612
+ return finishTask(
613
+ env,
614
+ p,
615
+ {
616
+ ...failTask(
617
+ task,
618
+ env.signal?.aborted
619
+ ? "Aborted"
620
+ : deadlineExceeded
621
+ ? formatDeadlineExceededError(task.deadlineMs ?? 0)
622
+ : setupError,
623
+ ),
624
+ failureKind: deadlineExceeded ? "deadline_exceeded" : undefined,
625
+ durationMs: Date.now() - startedAt,
626
+ },
627
+ task,
628
+ );
629
+ }
630
+
631
+ const executionTask: ResolvedTask = {
632
+ ...task,
633
+ cwd: workspace.cwd,
634
+ };
635
+ let result: TaskResult | undefined;
636
+ let cleanupError: string | undefined;
637
+ let needsCorrection = false;
638
+ let telemetryTaskId: string | undefined;
639
+ let retries = 0;
640
+ try {
641
+ const preMappingResult = await runResolvedTaskCore(
642
+ env,
643
+ executionTask,
644
+ p,
645
+ taskIndex,
646
+ {
647
+ taskStartedAt: startedAt,
648
+ deadlineAt,
649
+ },
650
+ );
651
+ result = preMappingResult;
652
+ // Capture metadata before scratch wrapping creates a new result object.
653
+ // Both values belong to the logical task and must survive that clone.
654
+ telemetryTaskId = taskTelemetryIds.get(result);
655
+ retries = taskRetryCounts.get(result) ?? 0;
656
+ result = {
657
+ ...result,
658
+ workspace: "scratch",
659
+ sessionFile: undefined,
660
+ touchedFiles: await Promise.all(
661
+ result.touchedFiles.map((file) => workspace.resolveReportedPath(file)),
662
+ ),
663
+ // Writes inside scratch are discarded and cannot conflict. Explicit
664
+ // writes outside scratch (for example an absolute host path) persist and
665
+ // must remain attributable for overlap warnings. Resolve those paths
666
+ // physically so aliases to the same host file compare equally.
667
+ attributedFiles: (
668
+ await Promise.all(
669
+ (result.attributedFiles ?? []).map((file) =>
670
+ workspace.resolveAttributedPath(file),
671
+ ),
672
+ )
673
+ ).filter((file): file is string => file !== undefined),
674
+ };
675
+ } catch (error) {
676
+ result = {
677
+ ...failTask(task, error instanceof Error ? error.message : String(error)),
678
+ ...(result
679
+ ? {
680
+ tokens: result.tokens,
681
+ usage: result.usage,
682
+ }
683
+ : {}),
684
+ workspace: "scratch",
685
+ durationMs: Date.now() - startedAt,
686
+ };
687
+ // runResolvedTaskCore may already have recorded/notified a successful
688
+ // result before path mapping failed. Correct those observable outcomes.
689
+ needsCorrection = true;
690
+ } finally {
691
+ try {
692
+ await workspace.cleanup();
693
+ } catch (error) {
694
+ cleanupError = `Scratch workspace cleanup failed: ${error instanceof Error ? error.message : String(error)}`;
695
+ console.error("[delegate] scratch workspace cleanup failed", error);
696
+ }
697
+ }
698
+
699
+ if (!result) {
700
+ result = {
701
+ ...failTask(task, "Scratch task failed before producing a result."),
702
+ workspace: "scratch",
703
+ durationMs: Date.now() - startedAt,
704
+ };
705
+ }
706
+ if (cleanupError || needsCorrection) {
707
+ result = {
708
+ ...result,
709
+ error: cleanupError
710
+ ? result.error
711
+ ? `${result.error}\n${cleanupError}`
712
+ : cleanupError
713
+ : result.error,
714
+ durationMs: Math.max(result.durationMs, Date.now() - startedAt),
715
+ };
716
+ updateProgressFromResult(p, result);
717
+ if (env.telemetryCallId) {
718
+ // runResolvedTaskCore already recorded the pre-cleanup result. Telemetry
719
+ // rows are upserts, so replace it with the actual returned outcome while
720
+ // preserving the retry count captured by finishTask.
721
+ recordTask({
722
+ id: telemetryTaskId,
723
+ callId: env.telemetryCallId,
724
+ generation: env.telemetryGeneration,
725
+ async: env.async ?? false,
726
+ taskIndex: p.index,
727
+ task,
728
+ progress: p,
729
+ result,
730
+ retries,
731
+ });
732
+ }
733
+ env.onStatusChange?.();
734
+ }
735
+ return result;
736
+ }
737
+
738
+ async function runResolvedTaskCore(
739
+ env: TaskRunEnv,
740
+ task: ResolvedTask,
741
+ p: TaskProgress,
742
+ taskIndex: number,
743
+ timing?: { taskStartedAt: number; deadlineAt: number | undefined },
552
744
  ): Promise<TaskResult> {
553
745
  try {
554
746
  // ── Aborted before we started? ───────────────────────────────────
555
747
  if (env.signal?.aborted) {
556
- return finishTask(env, p, failTask(task, "Aborted"));
748
+ return finishTask(env, p, failTask(task, "Aborted"), task);
557
749
  }
558
750
 
559
751
  // ── Session busy guard (defense-in-depth) ────────────────────────
@@ -563,7 +755,7 @@ async function runResolvedTaskUnlocked(
563
755
  const busyTicketId = isSessionBusy(task.sessionId);
564
756
  if (busyTicketId && busyTicketId !== env.ticketId) {
565
757
  const msg = `Session '${task.sessionId}' is already in use by ticket ${busyTicketId}. Each session can only handle one task at a time.`;
566
- return finishTask(env, p, failTask(task, msg));
758
+ return finishTask(env, p, failTask(task, msg), task);
567
759
  }
568
760
  }
569
761
 
@@ -577,6 +769,7 @@ async function runResolvedTaskUnlocked(
577
769
  env,
578
770
  p,
579
771
  failTask(task, "sessionAction='close' requires sessionId."),
772
+ task,
580
773
  );
581
774
  }
582
775
  // The per-session lock for action-based operations is already held by the
@@ -593,6 +786,7 @@ async function runResolvedTaskUnlocked(
593
786
  : `Session '${task.sessionId}' not found.`,
594
787
  Date.now() - env.delegateStartedAt,
595
788
  ),
789
+ task,
596
790
  );
597
791
  }
598
792
 
@@ -605,17 +799,19 @@ async function runResolvedTaskUnlocked(
605
799
  `Active sessions:\n${pool.listPooledAgents().join("\n")}`,
606
800
  Date.now() - env.delegateStartedAt,
607
801
  ),
802
+ task,
608
803
  );
609
804
  }
610
805
 
611
806
  let hasBashExecution = false;
612
807
  let cumulativeTokens = 0;
613
808
  let cumulativeToolUses = 0;
614
- const taskStartedAt = Date.now();
809
+ const taskStartedAt = timing?.taskStartedAt ?? Date.now();
615
810
  const deadlineAt =
616
- task.deadlineMs && task.deadlineMs > 0
811
+ timing?.deadlineAt ??
812
+ (task.deadlineMs && task.deadlineMs > 0
617
813
  ? taskStartedAt + task.deadlineMs
618
- : undefined;
814
+ : undefined);
619
815
  let accumulatedUsage = emptyUsage();
620
816
 
621
817
  const onAttemptProgress = (u: AgentProgressUpdate): void => {
@@ -821,16 +1017,22 @@ async function runResolvedTaskUnlocked(
821
1017
  task,
822
1018
  err instanceof Error ? err.message : String(err),
823
1019
  );
824
- return finishTask(env, p, {
825
- ...failure,
826
- durationMs: Math.max(failure.durationMs, Date.now() - taskStartedAt),
827
- tokens: accumulatedUsage.totalTokens,
828
- usage: accumulatedUsage,
829
- });
1020
+ return finishTask(
1021
+ env,
1022
+ p,
1023
+ {
1024
+ ...failure,
1025
+ durationMs: Math.max(failure.durationMs, Date.now() - taskStartedAt),
1026
+ tokens: accumulatedUsage.totalTokens,
1027
+ usage: accumulatedUsage,
1028
+ },
1029
+ task,
1030
+ );
830
1031
  }
831
1032
 
832
1033
  const maxRetries = resolvedWholeTaskMaxRetries();
833
1034
  const baseDelayMs = resolvedWholeTaskBaseDelayMs();
1035
+ let retriesExecuted = 0;
834
1036
  for (
835
1037
  let retry = 0;
836
1038
  retry < maxRetries && canRetryWholeTask(task, result, hasBashExecution);
@@ -864,6 +1066,7 @@ async function runResolvedTaskUnlocked(
864
1066
  p.error = undefined;
865
1067
  p.failureKind = undefined;
866
1068
  env.onStatusChange?.();
1069
+ retriesExecuted++;
867
1070
  try {
868
1071
  result = await runAttempt();
869
1072
  } catch (err) {
@@ -881,12 +1084,18 @@ async function runResolvedTaskUnlocked(
881
1084
  }
882
1085
  }
883
1086
 
884
- return finishTask(env, p, {
885
- ...result,
886
- durationMs: Math.max(result.durationMs, Date.now() - taskStartedAt),
887
- tokens: Math.max(cumulativeTokens, accumulatedUsage.totalTokens),
888
- usage: accumulatedUsage,
889
- });
1087
+ return finishTask(
1088
+ env,
1089
+ p,
1090
+ {
1091
+ ...result,
1092
+ durationMs: Math.max(result.durationMs, Date.now() - taskStartedAt),
1093
+ tokens: Math.max(cumulativeTokens, accumulatedUsage.totalTokens),
1094
+ usage: accumulatedUsage,
1095
+ },
1096
+ task,
1097
+ retriesExecuted,
1098
+ );
890
1099
  } catch (err) {
891
1100
  // Any acquired session is released by runAttempt's finally before an
892
1101
  // exception reaches this boundary. This outer catch handles unexpected
@@ -896,6 +1105,7 @@ async function runResolvedTaskUnlocked(
896
1105
  env,
897
1106
  p,
898
1107
  failTask(task, err instanceof Error ? err.message : String(err)),
1108
+ task,
899
1109
  );
900
1110
  }
901
1111
  }
package/manual.ts CHANGED
@@ -185,8 +185,9 @@ export function getSubagentManualMarkdown(
185
185
  "```",
186
186
  "",
187
187
  '- `delegate({ ticketAction: "poll" })` \u2014 list all tickets',
188
- '- `delegate({ ticketAction: "poll", ticket: "abc123" })` \u2014 check one ticket',
189
- '- `delegate({ ticketAction: "wait", ticket: "abc123", timeoutMs: 600000 })` \u2014 block until finished or timeout',
188
+ '- `delegate({ ticketAction: "poll", ticket: "abc123" })` \u2014 take one progress snapshot',
189
+ '- `delegate({ ticketAction: "wait", ticket: "abc123" })` \u2014 block until finished; omit `timeoutMs` when the result is needed this turn',
190
+ '- `delegate({ ticketAction: "wait", ticket: "abc123", timeoutMs: 600000 })` \u2014 bounded wait; timeout includes the latest snapshot, so do not poll afterward',
190
191
  '- `delegate({ ticketAction: "cancel", ticket: "abc123" })` \u2014 preview activity and partial effects before cancelling',
191
192
  '- `delegate({ ticketAction: "cancel", ticket: "abc123", force: true })` \u2014 abort after review',
192
193
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bermudi/pi-delegate",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Delegate tool for the Pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
package/schema.ts CHANGED
@@ -103,6 +103,13 @@ export const delegateTaskSchema = Type.Object({
103
103
  "Wall-clock budget (ms) from run start after queueing. Cooperative abort; side effects remain. Omit disables.",
104
104
  }),
105
105
  ),
106
+ workspace: Type.Optional(
107
+ StringEnum(["shared", "scratch"], {
108
+ description:
109
+ "scratch=disposable CoW project copy; relative edits are discarded. Not security isolation. Default=shared.",
110
+ default: "shared",
111
+ }),
112
+ ),
106
113
  });
107
114
 
108
115
  // Single source of truth for registration and generated help. The exported
@@ -139,14 +146,14 @@ export const delegateArgumentsSchema = Type.Object({
139
146
  Type.Number({
140
147
  minimum: 0,
141
148
  description:
142
- "Bounds wait (ms); omit to block until settled. Timeout never cancels the ticket.",
149
+ "Bounds wait (ms); omit to block until settled. Timeout returns a snapshot; do not poll afterward.",
143
150
  }),
144
151
  ),
145
152
  tasks: Type.Optional(
146
153
  Type.Array(delegateTaskSchema, {
147
154
  minItems: 0,
148
155
  description:
149
- "Tasks share the real filesystem and run concurrently; separate dependent/shared-file work. []=full manual.",
156
+ "Tasks run concurrently; shared workspaces share files. scratch uses a disposable CoW copy. []=full manual.",
150
157
  }),
151
158
  ),
152
159
  });
@@ -167,6 +174,7 @@ const TASK_FIELD_NAMES = [
167
174
  "sessionAction",
168
175
  "resumeFrom",
169
176
  "deadlineMs",
177
+ "workspace",
170
178
  ] as const;
171
179
 
172
180
  /** Every field a task entry may carry. Anything else is a model mistake —
@@ -317,6 +325,12 @@ export function validateDelegateOperation(
317
325
  if (typeof rawTask.deadlineMs === "number" && !(rawTask.deadlineMs > 0)) {
318
326
  return `task ${index + 1}: deadlineMs must be a positive number of milliseconds.`;
319
327
  }
328
+ if (
329
+ task.workspace === "scratch" &&
330
+ (task.sessionId || task.resumeFrom || sessionAction !== undefined)
331
+ ) {
332
+ return `task ${index + 1}: workspace 'scratch' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction.`;
333
+ }
320
334
  if (sessionAction === "close") {
321
335
  if (!task.sessionId) {
322
336
  return `task ${index + 1}: sessionAction 'close' requires sessionId.`;
@@ -208,6 +208,11 @@ export function resolveTasks(
208
208
  );
209
209
  let tools: string[] = [];
210
210
  const warnings: string[] = [];
211
+ if (t.workspace === "scratch") {
212
+ warnings.push(
213
+ "Scratch workspace: relative file changes run in a disposable CoW copy and are discarded.",
214
+ );
215
+ }
211
216
 
212
217
  // Prompt is required for fresh tasks. ResumeFrom provides context already.
213
218
  if (
@@ -418,6 +423,7 @@ export function resolveTasks(
418
423
  ...t,
419
424
  id: t.id,
420
425
  cwd,
426
+ workspace: t.workspace ?? "shared",
421
427
  systemPrompt,
422
428
  model: model!,
423
429
  tools,