@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 +78 -0
- package/package.json +3 -3
- package/src/runtime/tasks.test.ts +40 -0
- package/src/runtime/tasks.ts +55 -1
- package/src/tools/task.test.ts +55 -0
- package/src/tools/task.ts +80 -1
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.
|
|
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.
|
|
24
|
-
"@pi-ohm/tui": "0.6.4-dev.
|
|
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");
|
package/src/runtime/tasks.ts
CHANGED
|
@@ -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.
|
|
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
|
|
package/src/tools/task.test.ts
CHANGED
|
@@ -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:
|
|
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
|