@nicknisi/pi-workflows 0.1.0 → 0.2.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.
Files changed (2) hide show
  1. package/dist/index.js +43 -24
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -36,13 +36,7 @@ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
36
36
  const MAX_TIMEOUT_MS = 30 * 60 * 1000;
37
37
  const MAX_RESULT_CHARS = 16 * 1024;
38
38
  const MAX_LOG_CHARS = 2000;
39
- // ── Live runId → AbortController, so `stop` can cancel in-flight spawns. ──
40
- // Mirrors @nicknisi/pi-subagents' cascading-cancellation registry. Each
41
- // agent() call spawns detached and registers its controller here; stop(runId)
42
- // aborts it. Runs from other hosts show up via readRunArtifacts but are not
43
- // cancellable here (they belong to a different process).
44
- const cancellables = new Map();
45
- function spawnCancellable(runtime, opts, externalSignal) {
39
+ function spawnCancellable(cancellables, runtime, opts, externalSignal) {
46
40
  const controller = new AbortController();
47
41
  const onExternalAbort = () => controller.abort();
48
42
  if (externalSignal) {
@@ -59,7 +53,7 @@ function spawnCancellable(runtime, opts, externalSignal) {
59
53
  });
60
54
  return Object.assign(done, { runId });
61
55
  }
62
- function makeSpawnFn(runtime, cwd, externalSignal) {
56
+ function makeSpawnFn(cancellables, runtime, cwd, externalSignal) {
63
57
  return async (opts) => {
64
58
  const spawnOpts = {
65
59
  prompt: opts.prompt,
@@ -79,7 +73,7 @@ function makeSpawnFn(runtime, cwd, externalSignal) {
79
73
  spawnOpts.tools = opts.tools;
80
74
  else
81
75
  spawnOpts.tools = ['read', 'grep', 'find', 'ls'];
82
- const res = await spawnCancellable(runtime, spawnOpts, externalSignal);
76
+ const res = await spawnCancellable(cancellables, runtime, spawnOpts, externalSignal);
83
77
  // Surface the worktree `.patch` path (recorded on the run record after
84
78
  // settle) so workflow scripts can return it for the `/patches` apply flow.
85
79
  if (res.ok) {
@@ -199,6 +193,7 @@ function formatRunResult(result, label) {
199
193
  }
200
194
  // ── Extension ─────────────────────────────────────────────────────────────
201
195
  export default function workflows(pi) {
196
+ const cancellables = new Map();
202
197
  const runtime = createSubagentRuntime({ namespace: NAMESPACE, artifactsDir: ARTIFACTS_ROOT });
203
198
  sweepRunArtifactsOnce(ARTIFACTS_ROOT);
204
199
  pi.registerTool({
@@ -296,7 +291,7 @@ export default function workflows(pi) {
296
291
  if (!runId) {
297
292
  return { content: [{ type: 'text', text: 'stop requires runId.' }], details: {} };
298
293
  }
299
- const controller = resolveCancellable(runtime, runId);
294
+ const controller = resolveCancellable(cancellables, runtime, runId);
300
295
  if (!controller) {
301
296
  const record = findRun(runtime, runId);
302
297
  const msg = record && (record.status === 'running' || record.status === 'queued')
@@ -355,17 +350,40 @@ export default function workflows(pi) {
355
350
  details: {},
356
351
  };
357
352
  }
358
- const spawnFn = makeSpawnFn(runtime, ctx.cwd, signal ?? undefined);
353
+ // One controller for BOTH abort sources: the tool's signal AND the
354
+ // timeout. spawnCancellable wires it into every child spawn, so firing
355
+ // it actually cancels in-flight work (previously the timeout controller
356
+ // was connected to nothing — dead code).
359
357
  const controller = new AbortController();
360
- const timer = setTimeout(() => controller.abort(), timeoutMs);
358
+ let timedOut = false;
359
+ const onToolAbort = () => controller.abort();
360
+ if (signal) {
361
+ if (signal.aborted)
362
+ controller.abort();
363
+ else
364
+ signal.addEventListener('abort', onToolAbort, { once: true });
365
+ }
366
+ const timer = setTimeout(() => {
367
+ timedOut = true;
368
+ controller.abort();
369
+ }, timeoutMs);
370
+ const spawnFn = makeSpawnFn(cancellables, runtime, ctx.cwd, controller.signal);
371
+ const timeoutPromise = new Promise((_, reject) => {
372
+ controller.signal.addEventListener('abort', () => reject(new Error(timedOut
373
+ ? `Timed out after ${timeoutMs}ms (in-flight subagents were aborted)`
374
+ : 'Aborted by the host session')), { once: true });
375
+ });
361
376
  try {
362
- const result = await runScript({
363
- script: src,
364
- args: params.args,
365
- spawn: spawnFn,
366
- cwd: ctx.cwd,
367
- onLog: () => { },
368
- });
377
+ const result = await Promise.race([
378
+ runScript({
379
+ script: src,
380
+ args: params.args,
381
+ spawn: spawnFn,
382
+ cwd: ctx.cwd,
383
+ onLog: () => { },
384
+ }),
385
+ timeoutPromise,
386
+ ]);
369
387
  const text = formatRunResult(result, label);
370
388
  return {
371
389
  content: [{ type: 'text', text }],
@@ -387,6 +405,7 @@ export default function workflows(pi) {
387
405
  }
388
406
  finally {
389
407
  clearTimeout(timer);
408
+ signal?.removeEventListener('abort', onToolAbort);
390
409
  }
391
410
  },
392
411
  });
@@ -403,12 +422,12 @@ export default function workflows(pi) {
403
422
  return subs.map((s) => ({ value: s + ' ', label: s }));
404
423
  },
405
424
  handler: async (args, ctx) => {
406
- await cmdWf(args, ctx, runtime);
425
+ await cmdWf(args, ctx, runtime, cancellables);
407
426
  },
408
427
  });
409
428
  }
410
429
  // ── /wf handler (shared, typed against the runtime) ───────────────────────
411
- async function cmdWf(args, ctx, runtime) {
430
+ async function cmdWf(args, ctx, runtime, cancellables) {
412
431
  const parts = args.trim().split(/\s+/).filter(Boolean);
413
432
  const sub = parts[0];
414
433
  if (!sub || sub === 'list') {
@@ -453,7 +472,7 @@ async function cmdWf(args, ctx, runtime) {
453
472
  }
454
473
  ctx.ui.setStatus('workflows', `running ${name}…`);
455
474
  try {
456
- const spawnFn = makeSpawnFn(runtime, ctx.cwd, ctx.signal ?? undefined);
475
+ const spawnFn = makeSpawnFn(cancellables, runtime, ctx.cwd, ctx.signal ?? undefined);
457
476
  const result = await runScript({ script: src, args: parsedArgs, spawn: spawnFn, cwd: ctx.cwd });
458
477
  ctx.ui.notify(formatRunResult(result, name), 'info');
459
478
  }
@@ -485,7 +504,7 @@ async function cmdWf(args, ctx, runtime) {
485
504
  ctx.ui.notify('Usage: /wf stop <runId>', 'warning');
486
505
  return;
487
506
  }
488
- const controller = resolveCancellable(runtime, runId);
507
+ const controller = resolveCancellable(cancellables, runtime, runId);
489
508
  if (!controller) {
490
509
  const record = findRun(runtime, runId);
491
510
  ctx.ui.notify(record && (record.status === 'running' || record.status === 'queued')
@@ -520,7 +539,7 @@ function findRun(runtime, runId) {
520
539
  return undefined;
521
540
  }
522
541
  /** Resolve a cancellable controller by full id or unique prefix. */
523
- function resolveCancellable(_runtime, runId) {
542
+ function resolveCancellable(cancellables, _runtime, runId) {
524
543
  if (cancellables.has(runId))
525
544
  return cancellables.get(runId);
526
545
  if (runId.length >= 8) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nicknisi/pi-workflows",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Model-facing front door to the first-party workflow engine — run JS workflow scripts over the subagent runtime, replacing the third-party @quintinshaw/pi-dynamic-workflows extension",
5
5
  "keywords": [
6
6
  "pi",
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "typebox": "^1.1.0",
31
- "@nicknisi/pi-shared": "0.3.0"
31
+ "@nicknisi/pi-shared": "0.4.0"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@earendil-works/pi-coding-agent": "*"