@pi-ohm/subagents 0.6.4-dev.22169815567.1.cdde4e8 → 0.6.4-dev.22170486101.1.c0abdac

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 CHANGED
@@ -96,6 +96,76 @@ Nested interactive-shell outputs are sanitized to strip runtime metadata lines (
96
96
 
97
97
  For unknown tasks/expired tasks, error categorization is explicit: `error_category: "not_found"`.
98
98
 
99
+ ## Operator cookbook
100
+
101
+ ### 1) Sync vs async recommendation matrix
102
+
103
+ | scenario | recommended mode | why |
104
+ | ----------------------------------------------- | ---------------------------------------- | ------------------------------------------------------- |
105
+ | quick lookup, single task, result needed now | `start` (default sync) | simplest UX; one call, one terminal result |
106
+ | long-running analysis where caller can continue | `start async:true` + `wait/status` later | avoids blocking; keeps task lifecycle explicit |
107
+ | fan-out independent tasks | `start tasks[] parallel:true` | deterministic ordered aggregation + bounded concurrency |
108
+ | follow-up on active task | `send` | preserves task history + follow-up prompts |
109
+ | stop background task | `cancel` | explicit terminal transition + abort propagation |
110
+
111
+ Default policy: use sync first; opt into async only when needed.
112
+
113
+ ### 2) Backend tradeoff matrix
114
+
115
+ | backend | strengths | tradeoffs | when to pick |
116
+ | ----------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------ |
117
+ | `interactive-shell` (default) | mature nested CLI behavior; straightforward rollback | text-capture based transcript fidelity | safe baseline/default |
118
+ | `interactive-sdk` (opt-in) | structured tool/assistant events, event-derived rows, better inline fidelity | newer path; needs smoke/ops confidence | pre-prod validation + richer observability |
119
+ | `none` | deterministic scaffold output | no real execution | testing/demo/debug wiring only |
120
+ | `custom-plugin` | reserved hook | not implemented (`unsupported_subagent_backend`) | none currently |
121
+
122
+ Fallback policy:
123
+
124
+ - enable `OHM_SUBAGENTS_SDK_FALLBACK_TO_CLI=true` to downgrade only recoverable SDK bootstrap failures (`task_backend_execution_failed`) from SDK -> CLI path.
125
+
126
+ ### 3) Recommended smoke matrix
127
+
128
+ ```bash
129
+ # default backend visibility
130
+ printf '/ohm-subagents\n' | pi -e ./packages/subagents/extension.ts
131
+
132
+ # explicit sdk backend visibility
133
+ mkdir -p /tmp/pi-ohm-sdk-smoke
134
+ cat >/tmp/pi-ohm-sdk-smoke/ohm.json <<'EOF'
135
+ { "subagentBackend": "interactive-sdk" }
136
+ EOF
137
+ printf '/ohm-subagents\n' | PI_CONFIG_DIR=/tmp/pi-ohm-sdk-smoke pi -e ./packages/subagents/extension.ts
138
+ ```
139
+
140
+ Task lifecycle smoke checklist:
141
+
142
+ 1. sync single `start`
143
+ 2. async single `start async:true` + `status` + `wait`
144
+ 3. `cancel` running task
145
+ 4. batch partial acceptance (`tasks[]` mixed validity)
146
+ 5. timeout path (`wait timeout_ms`)
147
+ 6. follow-up `send` on running task
148
+
149
+ ### 4) Troubleshooting quick map
150
+
151
+ | symptom | likely cause | check/fix |
152
+ | --------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------ |
153
+ | output looks scaffolded/echoed | backend is `none` | set `subagentBackend` to `interactive-shell` or `interactive-sdk` |
154
+ | sdk selected but execution drops to cli | fallback env enabled and sdk hit recoverable bootstrap failure | inspect `OHM_SUBAGENTS_SDK_FALLBACK_TO_CLI`; disable to keep hard sdk failures |
155
+ | `task_wait_timeout` | task still non-terminal at timeout | increase `timeout_ms`, poll with `status`, or reduce batch size |
156
+ | `task_wait_aborted` | caller signal cancelled wait | retry wait with active signal |
157
+ | `task_expired` on old IDs | retention/capacity eviction | increase retention/cap env knobs; treat task IDs as ephemeral |
158
+ | too many inline progress updates | high-frequency non-terminal emissions | increase `OHM_SUBAGENTS_ONUPDATE_THROTTLE_MS` |
159
+
160
+ ### 5) Guardrail env knobs
161
+
162
+ - `OHM_SUBAGENTS_TASK_RETENTION_MS` — terminal task retention window
163
+ - `OHM_SUBAGENTS_TASK_MAX_EVENTS` — per-task structured event cap
164
+ - `OHM_SUBAGENTS_TASK_MAX_ENTRIES` — in-memory task registry cap
165
+ - `OHM_SUBAGENTS_TASK_MAX_EXPIRED_ENTRIES` — expired-task reason cache cap
166
+ - `OHM_SUBAGENTS_ONUPDATE_THROTTLE_MS` — non-terminal onUpdate emission throttle
167
+ - `OHM_SUBAGENTS_OUTPUT_MAX_CHARS` — terminal output payload cap
168
+
99
169
  ### Output truncation policy
100
170
 
101
171
  Task output returned in tool payloads is capped to prevent oversized context injection.
@@ -160,7 +230,15 @@ Persistence details:
160
230
 
161
231
  - default snapshot path: `${PI_CONFIG_DIR|PI_CODING_AGENT_DIR|PI_AGENT_DIR|~/.pi/agent}/ohm.subagents.tasks.json`
162
232
  - retention window is configurable via `OHM_SUBAGENTS_TASK_RETENTION_MS` (positive integer ms)
233
+ - per-task structured event timeline cap is configurable via `OHM_SUBAGENTS_TASK_MAX_EVENTS`
234
+ (default `120`)
235
+ - in-memory task registry capacity is configurable via `OHM_SUBAGENTS_TASK_MAX_ENTRIES`
236
+ (default `200`); oldest terminal tasks are evicted first once cap is exceeded
237
+ - expired-task reason cache is configurable via `OHM_SUBAGENTS_TASK_MAX_EXPIRED_ENTRIES`
238
+ (default `500`)
163
239
  - corrupt snapshot files are auto-recovered to `*.corrupt-<epoch>` and runtime falls back to empty state
240
+ - inline `onUpdate` emission is throttled via `OHM_SUBAGENTS_ONUPDATE_THROTTLE_MS`
241
+ (default `120ms`) with duplicate-frame suppression to avoid async wait/update spam
164
242
 
165
243
  ## Migration notes
166
244
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-ohm/subagents",
3
- "version": "0.6.4-dev.22169815567.1.cdde4e8",
3
+ "version": "0.6.4-dev.22170486101.1.c0abdac",
4
4
  "homepage": "https://github.com/pi-ohm/pi-ohm/tree/dev/packages/subagents#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,8 +20,8 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@mariozechner/pi-coding-agent": "catalog:pi",
23
- "@pi-ohm/config": "0.6.4-dev.22169815567.1.cdde4e8",
24
- "@pi-ohm/tui": "0.6.4-dev.22169815567.1.cdde4e8",
23
+ "@pi-ohm/config": "0.6.4-dev.22170486101.1.c0abdac",
24
+ "@pi-ohm/tui": "0.6.4-dev.22170486101.1.c0abdac",
25
25
  "better-result": "catalog:",
26
26
  "zod": "catalog:"
27
27
  },
@@ -615,6 +615,46 @@ defineTest("retention policy expires terminal tasks with explicit error reason",
615
615
  assert.match(String(lookup?.errorMessage), /retention policy/);
616
616
  });
617
617
 
618
+ defineTest("capacity policy evicts oldest terminal tasks when maxTasks is exceeded", () => {
619
+ let now = 1000;
620
+ const store = createInMemoryTaskRuntimeStore({
621
+ now: () => now,
622
+ retentionMs: 60_000,
623
+ maxTasks: 2,
624
+ });
625
+
626
+ for (const taskId of ["task_1", "task_2", "task_3"]) {
627
+ const created = store.createTask({
628
+ taskId,
629
+ subagent: finderSubagent,
630
+ description: `Task ${taskId}`,
631
+ prompt: `Prompt ${taskId}`,
632
+ backend: "scaffold",
633
+ invocation: "task-routed",
634
+ });
635
+ assert.equal(Result.isOk(created), true);
636
+
637
+ now += 5;
638
+ const running = store.markRunning(taskId, `Running ${taskId}`);
639
+ assert.equal(Result.isOk(running), true);
640
+
641
+ now += 5;
642
+ const succeeded = store.markSucceeded(taskId, `Done ${taskId}`, `Output ${taskId}`);
643
+ assert.equal(Result.isOk(succeeded), true);
644
+ }
645
+
646
+ const remainingIds = store
647
+ .listTasks()
648
+ .map((snapshot) => snapshot.id)
649
+ .sort();
650
+ assert.deepEqual(remainingIds, ["task_2", "task_3"]);
651
+
652
+ const evicted = store.getTasks(["task_1"])[0];
653
+ assert.equal(evicted?.found, false);
654
+ assert.equal(evicted?.errorCode, "task_expired");
655
+ assert.match(String(evicted?.errorMessage), /capacity policy/);
656
+ });
657
+
618
658
  defineTest("corrupt persistence snapshot falls back to empty store and records diagnostics", () => {
619
659
  withTempDir((dir) => {
620
660
  const filePath = join(dir, "tasks.json");
@@ -19,6 +19,8 @@ export interface TaskRuntimeObservability {
19
19
  const TASK_PERSISTENCE_SCHEMA_VERSION = 1;
20
20
  const DEFAULT_RETENTION_MS = 1000 * 60 * 60 * 24;
21
21
  const DEFAULT_MAX_EVENTS_PER_TASK = 120;
22
+ const DEFAULT_MAX_TASKS = 200;
23
+ const DEFAULT_MAX_EXPIRED_TASKS = 500;
22
24
 
23
25
  export interface TaskRuntimeSnapshot {
24
26
  readonly id: string;
@@ -748,6 +750,8 @@ export interface InMemoryTaskRuntimeStoreOptions {
748
750
  readonly retentionMs?: number;
749
751
  readonly persistence?: TaskRuntimePersistence;
750
752
  readonly maxEventsPerTask?: number;
753
+ readonly maxTasks?: number;
754
+ readonly maxExpiredTasks?: number;
751
755
  }
752
756
 
753
757
  class InMemoryTaskRuntimeStore implements TaskRuntimeStore {
@@ -758,6 +762,8 @@ class InMemoryTaskRuntimeStore implements TaskRuntimeStore {
758
762
  private readonly retentionMs: number;
759
763
  private readonly persistence: TaskRuntimePersistence | undefined;
760
764
  private readonly maxEventsPerTask: number;
765
+ private readonly maxTasks: number;
766
+ private readonly maxExpiredTasks: number;
761
767
 
762
768
  constructor(options: InMemoryTaskRuntimeStoreOptions = {}) {
763
769
  this.now = options.now ?? (() => Date.now());
@@ -770,6 +776,12 @@ class InMemoryTaskRuntimeStore implements TaskRuntimeStore {
770
776
  options.maxEventsPerTask !== undefined && options.maxEventsPerTask > 0
771
777
  ? options.maxEventsPerTask
772
778
  : DEFAULT_MAX_EVENTS_PER_TASK;
779
+ this.maxTasks =
780
+ options.maxTasks !== undefined && options.maxTasks > 0 ? options.maxTasks : DEFAULT_MAX_TASKS;
781
+ this.maxExpiredTasks =
782
+ options.maxExpiredTasks !== undefined && options.maxExpiredTasks > 0
783
+ ? options.maxExpiredTasks
784
+ : DEFAULT_MAX_EXPIRED_TASKS;
773
785
 
774
786
  this.hydrateFromPersistence();
775
787
  this.pruneExpiredTerminalTasks();
@@ -789,6 +801,8 @@ class InMemoryTaskRuntimeStore implements TaskRuntimeStore {
789
801
  );
790
802
  }
791
803
 
804
+ this.expiredTasks.delete(input.taskId);
805
+
792
806
  const now = this.now();
793
807
  const initialRecord: TaskRecord = {
794
808
  id: input.taskId,
@@ -816,6 +830,7 @@ class InMemoryTaskRuntimeStore implements TaskRuntimeStore {
816
830
  };
817
831
 
818
832
  this.tasks.set(input.taskId, entry);
833
+ this.pruneTaskCapacity();
819
834
  this.persistCurrentState();
820
835
  return Result.ok(toTaskRuntimeSnapshot(entry));
821
836
  }
@@ -1259,6 +1274,12 @@ class InMemoryTaskRuntimeStore implements TaskRuntimeStore {
1259
1274
  this.tasks.set(validatedRecord.value.id, hydrated);
1260
1275
  }
1261
1276
 
1277
+ const sizeBeforeCapacityPrune = this.tasks.size;
1278
+ this.pruneTaskCapacity();
1279
+ if (this.tasks.size !== sizeBeforeCapacityPrune) {
1280
+ changed = true;
1281
+ }
1282
+
1262
1283
  if (changed) {
1263
1284
  this.persistCurrentState();
1264
1285
  }
@@ -1276,7 +1297,7 @@ class InMemoryTaskRuntimeStore implements TaskRuntimeStore {
1276
1297
  if (ageMs < this.retentionMs) continue;
1277
1298
 
1278
1299
  this.tasks.delete(taskId);
1279
- this.expiredTasks.set(
1300
+ this.rememberExpiredTask(
1280
1301
  taskId,
1281
1302
  `Task id '${taskId}' expired by retention policy after ${this.retentionMs}ms`,
1282
1303
  );
@@ -1288,6 +1309,39 @@ class InMemoryTaskRuntimeStore implements TaskRuntimeStore {
1288
1309
  }
1289
1310
  }
1290
1311
 
1312
+ private rememberExpiredTask(taskId: string, reason: string): void {
1313
+ this.expiredTasks.delete(taskId);
1314
+ this.expiredTasks.set(taskId, reason);
1315
+
1316
+ while (this.expiredTasks.size > this.maxExpiredTasks) {
1317
+ const oldest = this.expiredTasks.keys().next();
1318
+ if (oldest.done) return;
1319
+ this.expiredTasks.delete(oldest.value);
1320
+ }
1321
+ }
1322
+
1323
+ private pruneTaskCapacity(): void {
1324
+ if (this.tasks.size <= this.maxTasks) return;
1325
+
1326
+ const evictable = [...this.tasks.entries()]
1327
+ .filter(([, entry]) => isTerminalState(entry.record.state))
1328
+ .sort((left, right) => {
1329
+ const leftEnded = left[1].record.endedAtEpochMs ?? left[1].record.updatedAtEpochMs;
1330
+ const rightEnded = right[1].record.endedAtEpochMs ?? right[1].record.updatedAtEpochMs;
1331
+ return leftEnded - rightEnded;
1332
+ });
1333
+
1334
+ for (const [taskId] of evictable) {
1335
+ if (this.tasks.size <= this.maxTasks) return;
1336
+
1337
+ this.tasks.delete(taskId);
1338
+ this.rememberExpiredTask(
1339
+ taskId,
1340
+ `Task id '${taskId}' evicted by capacity policy after reaching max ${this.maxTasks} tasks`,
1341
+ );
1342
+ }
1343
+ }
1344
+
1291
1345
  private persistCurrentState(): void {
1292
1346
  if (!this.persistence) return;
1293
1347
 
@@ -1083,6 +1083,61 @@ defineTest("runTaskToolMvp wait exposes aborted outcome contract", async () => {
1083
1083
  await Promise.resolve();
1084
1084
  });
1085
1085
 
1086
+ defineTest("runTaskToolMvp throttles duplicate wait progress updates", async () => {
1087
+ const previousThrottle = process.env.OHM_SUBAGENTS_ONUPDATE_THROTTLE_MS;
1088
+ process.env.OHM_SUBAGENTS_ONUPDATE_THROTTLE_MS = "1000";
1089
+
1090
+ try {
1091
+ const backend = new DeferredBackend();
1092
+ const deps = makeDeps({ backend });
1093
+
1094
+ const started = await runTask({
1095
+ params: {
1096
+ op: "start",
1097
+ async: true,
1098
+ subagent_type: "finder",
1099
+ description: "Throttle wait progress",
1100
+ prompt: "keep running",
1101
+ },
1102
+ cwd: "/tmp/project",
1103
+ signal: undefined,
1104
+ onUpdate: undefined,
1105
+ deps,
1106
+ });
1107
+
1108
+ assert.equal(started.details.status, "running");
1109
+
1110
+ const updates: string[] = [];
1111
+ const waited = await runTask({
1112
+ params: {
1113
+ op: "wait",
1114
+ ids: ["task_test_0001"],
1115
+ timeout_ms: 260,
1116
+ },
1117
+ cwd: "/tmp/project",
1118
+ signal: undefined,
1119
+ onUpdate: (partial) => {
1120
+ updates.push(JSON.stringify(partial.details));
1121
+ },
1122
+ deps,
1123
+ });
1124
+
1125
+ assert.equal(waited.details.error_code, "task_wait_timeout");
1126
+ assert.equal(updates.length, 2);
1127
+ assert.notEqual(updates[0], updates[1]);
1128
+
1129
+ backend.resolveSuccess(0, "Finder: Throttle wait progress", "done output");
1130
+ await Promise.resolve();
1131
+ await Promise.resolve();
1132
+ } finally {
1133
+ if (previousThrottle === undefined) {
1134
+ delete process.env.OHM_SUBAGENTS_ONUPDATE_THROTTLE_MS;
1135
+ } else {
1136
+ process.env.OHM_SUBAGENTS_ONUPDATE_THROTTLE_MS = previousThrottle;
1137
+ }
1138
+ }
1139
+ });
1140
+
1086
1141
  defineTest("runTaskToolMvp status/wait include terminal outputs for async tasks", async () => {
1087
1142
  const backend = new DeferredBackend();
1088
1143
  const deps = makeDeps({ backend });
package/src/tools/task.ts CHANGED
@@ -175,6 +175,28 @@ function resolveCollapsedResultPreviewVisualLines(): number {
175
175
  return 12;
176
176
  }
177
177
 
178
+ function resolveTaskRetentionMs(): number | undefined {
179
+ return parsePositiveIntegerEnv("OHM_SUBAGENTS_TASK_RETENTION_MS");
180
+ }
181
+
182
+ function resolveTaskMaxEventsPerTask(): number | undefined {
183
+ return parsePositiveIntegerEnv("OHM_SUBAGENTS_TASK_MAX_EVENTS");
184
+ }
185
+
186
+ function resolveTaskMaxEntries(): number | undefined {
187
+ return parsePositiveIntegerEnv("OHM_SUBAGENTS_TASK_MAX_ENTRIES");
188
+ }
189
+
190
+ function resolveTaskMaxExpiredEntries(): number | undefined {
191
+ return parsePositiveIntegerEnv("OHM_SUBAGENTS_TASK_MAX_EXPIRED_ENTRIES");
192
+ }
193
+
194
+ function resolveOnUpdateThrottleMs(): number {
195
+ const fromEnv = parsePositiveIntegerEnv("OHM_SUBAGENTS_ONUPDATE_THROTTLE_MS");
196
+ if (fromEnv !== undefined) return fromEnv;
197
+ return 120;
198
+ }
199
+
178
200
  function resolveDefaultTaskPersistencePath(): string {
179
201
  const baseDir =
180
202
  process.env.PI_CONFIG_DIR ??
@@ -187,7 +209,10 @@ function resolveDefaultTaskPersistencePath(): string {
187
209
 
188
210
  const DEFAULT_TASK_STORE = createInMemoryTaskRuntimeStore({
189
211
  persistence: createJsonTaskRuntimePersistence(resolveDefaultTaskPersistencePath()),
190
- retentionMs: parsePositiveIntegerEnv("OHM_SUBAGENTS_TASK_RETENTION_MS"),
212
+ retentionMs: resolveTaskRetentionMs(),
213
+ maxEventsPerTask: resolveTaskMaxEventsPerTask(),
214
+ maxTasks: resolveTaskMaxEntries(),
215
+ maxExpiredTasks: resolveTaskMaxExpiredEntries(),
191
216
  });
192
217
 
193
218
  let taskSequence = 0;
@@ -1541,8 +1566,58 @@ interface RunTaskToolInput {
1541
1566
  type TaskToolUiHandle = NonNullable<RunTaskToolInput["ui"]>;
1542
1567
  const liveUiBySurface = new WeakMap<TaskToolUiHandle, TaskLiveUiCoordinator>();
1543
1568
  const liveUiHeartbeatBySurface = new WeakMap<TaskToolUiHandle, ReturnType<typeof setInterval>>();
1569
+ const onUpdateLastEmissionByCallback = new WeakMap<
1570
+ AgentToolUpdateCallback<TaskToolResultDetails>,
1571
+ {
1572
+ readonly atEpochMs: number;
1573
+ readonly signature: string;
1574
+ }
1575
+ >();
1544
1576
  const LIVE_UI_HEARTBEAT_MS = 120;
1545
1577
 
1578
+ function isThrottleBypassUpdate(details: TaskToolResultDetails): boolean {
1579
+ if (
1580
+ details.status === "succeeded" ||
1581
+ details.status === "failed" ||
1582
+ details.status === "cancelled"
1583
+ ) {
1584
+ return true;
1585
+ }
1586
+
1587
+ if (details.op === "wait") {
1588
+ if (details.done === true) return true;
1589
+ if (details.wait_status === "timeout" || details.wait_status === "aborted") return true;
1590
+ }
1591
+
1592
+ return false;
1593
+ }
1594
+
1595
+ function shouldEmitOnUpdate(
1596
+ callback: AgentToolUpdateCallback<TaskToolResultDetails>,
1597
+ details: TaskToolResultDetails,
1598
+ ): boolean {
1599
+ const nextSignature = JSON.stringify(details);
1600
+ const nowEpochMs = Date.now();
1601
+ const previous = onUpdateLastEmissionByCallback.get(callback);
1602
+
1603
+ if (previous && previous.signature === nextSignature) {
1604
+ return false;
1605
+ }
1606
+
1607
+ const bypassThrottle = isThrottleBypassUpdate(details);
1608
+ const throttleMs = resolveOnUpdateThrottleMs();
1609
+
1610
+ if (!bypassThrottle && previous && nowEpochMs - previous.atEpochMs < throttleMs) {
1611
+ return false;
1612
+ }
1613
+
1614
+ onUpdateLastEmissionByCallback.set(callback, {
1615
+ atEpochMs: nowEpochMs,
1616
+ signature: nextSignature,
1617
+ });
1618
+ return true;
1619
+ }
1620
+
1546
1621
  function getTaskLiveUiCoordinator(ui: TaskToolUiHandle): TaskLiveUiCoordinator {
1547
1622
  const existing = liveUiBySurface.get(ui);
1548
1623
  if (existing) return existing;
@@ -1614,6 +1689,10 @@ function emitTaskRuntimeUpdate(input: {
1614
1689
  return;
1615
1690
  }
1616
1691
 
1692
+ if (!shouldEmitOnUpdate(input.onUpdate, input.details)) {
1693
+ return;
1694
+ }
1695
+
1617
1696
  {
1618
1697
  const runtimeText =
1619
1698
  presentation.widgetLines.length > 0