@ct-agents/worker 0.1.4 → 0.1.6

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/package.json +5 -5
  2. package/src/index.ts +185 -35
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ct-agents/worker",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -12,10 +12,10 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "zod": "4.4.3",
15
- "@ct-agents/memory": "0.1.4",
16
- "@ct-agents/prompts": "0.1.4",
17
- "@ct-agents/protocol": "0.1.4",
18
- "@ct-agents/tools": "0.1.4"
15
+ "@ct-agents/memory": "0.1.6",
16
+ "@ct-agents/prompts": "0.1.6",
17
+ "@ct-agents/protocol": "0.1.6",
18
+ "@ct-agents/tools": "0.1.6"
19
19
  },
20
20
  "optionalDependencies": {
21
21
  "dockerode": "4.0.9"
package/src/index.ts CHANGED
@@ -21,12 +21,18 @@ import type {
21
21
  ToolWorkItem,
22
22
  WorkQueue,
23
23
  WorkStats,
24
+ CompleteWorkItemScope,
25
+ RenewWorkItemLeaseInput,
24
26
  } from '@ct-agents/protocol';
25
27
  import type { MemoryResource, SkillResource } from '@ct-agents/protocol';
26
28
  import { createLoadMemoryToolHandler, createUpdateMemoryToolHandler } from '@ct-agents/memory';
27
29
  import { createLoadSkillToolHandler } from '@ct-agents/prompts';
28
30
  import { createSandboxTools } from '@ct-agents/tools';
29
- import { createAskUserToolHandler, createWebSearchToolHandler } from '@ct-agents/tools/builtin-tools';
31
+ import {
32
+ createAskUserToolHandler,
33
+ createTodoWriteToolHandler,
34
+ createWebSearchToolHandler,
35
+ } from '@ct-agents/tools/builtin-tools';
30
36
  import { z } from 'zod';
31
37
  import {
32
38
  createPlatformDatabaseProxyResource,
@@ -89,7 +95,11 @@ export type EnvironmentWorkerInput = {
89
95
  resourceImpls?: EnvironmentWorkerResourceImplementations;
90
96
  platformResourceProxy?: EnvironmentWorkerPlatformResourceProxy;
91
97
  abortSignal?: AbortSignal;
92
- maxBatchSize?: number;
98
+ /** 单进程持续允许的最大 active Tool work 数。 */
99
+ maxConcurrency?: number;
100
+ /** active Tool lease 的续租周期,必须小于 loop 的 reclaimOlderThanMs。 */
101
+ leaseRenewIntervalMs?: number;
102
+ onError?: (error: unknown) => void | Promise<void>;
93
103
  harness?: {
94
104
  id: string;
95
105
  version: number;
@@ -136,6 +146,7 @@ export type RunContextResolverWorkerLoopInput = ContextResolverWorkerInput & {
136
146
  export const DEFAULT_RECLAIM_OLDER_THAN_MS = 300_000;
137
147
  export const DEFAULT_RECLAIM_INTERVAL_MS = 60_000;
138
148
  export const DEFAULT_CLAIM_BLOCK_MS = 15_000;
149
+ export const DEFAULT_WORK_LEASE_RENEW_INTERVAL_MS = 60_000;
139
150
 
140
151
  const PLATFORM_PROXY_RESOURCE_SLOTS = new Set(['database', 'skills', 'memory']);
141
152
 
@@ -146,6 +157,7 @@ export function createPlatformBuiltinToolHandlers(): ToolHandler[] {
146
157
  createLoadMemoryToolHandler(),
147
158
  createUpdateMemoryToolHandler(),
148
159
  createWebSearchToolHandler(),
160
+ createTodoWriteToolHandler(),
149
161
  ...createSandboxTools(),
150
162
  ];
151
163
  }
@@ -161,6 +173,13 @@ function normalizeNonNegativeInteger(value: number | undefined, fallback: number
161
173
  return Math.max(0, Math.trunc(value));
162
174
  }
163
175
 
176
+ function normalizePositiveInteger(value: number | undefined, fallback: number): number {
177
+ if (value === undefined || !Number.isFinite(value)) {
178
+ return fallback;
179
+ }
180
+ return Math.max(1, Math.trunc(value));
181
+ }
182
+
164
183
  class PlatformBuiltinToolRegistry implements ToolRegistry {
165
184
  private readonly builtinHandlers = new Map<string, ToolHandler<unknown, unknown>>();
166
185
 
@@ -212,45 +231,97 @@ export class EnvironmentWorker {
212
231
  }
213
232
 
214
233
  public async drainOnce(options: DrainOnceOptions = {}): Promise<number> {
234
+ const items = await this.claim(
235
+ normalizePositiveInteger(this.input.maxConcurrency, 1),
236
+ options,
237
+ );
238
+
239
+ await Promise.all(items.map((item) => this.handleItem(item)));
240
+ return items.length;
241
+ }
242
+
243
+ public async claim(max: number, options: DrainOnceOptions = {}): Promise<ToolWorkItem[]> {
215
244
  const executionMode = this.input.executionMode ?? 'self_hosted';
216
- const items = await this.input.workQueue.claim({
245
+ return this.input.workQueue.claim({
217
246
  appId: this.input.appId,
218
247
  environmentId: this.input.environmentId,
219
248
  executionMode,
220
- max: this.input.maxBatchSize ?? 1,
249
+ max: normalizePositiveInteger(max, 1),
221
250
  blockMs: options.blockMs,
222
251
  });
223
-
224
- await Promise.all(items.map((item) => this.handleItem(item)));
225
- return items.length;
226
252
  }
227
253
 
228
- private async handleItem(item: ToolWorkItem): Promise<void> {
229
- let result: ToolExecutionResult;
230
- const disposers: Array<() => Promise<void>> = [];
254
+ public async handleItem(item: ToolWorkItem): Promise<void> {
255
+ if (!item.leaseId) {
256
+ throw new Error(`claimed work item 缺少 leaseId:${item.workId}`);
257
+ }
258
+ const renewController = new AbortController();
259
+ const renewal = this.renewLeaseUntilStopped(item, renewController.signal);
231
260
  try {
232
- const handler = this.input.toolRegistry.getTool(item.toolName);
233
- const execution = await this.executeHandler(handler, item, disposers);
234
- result = toToolExecutionResult(item.workId, handler, execution);
235
- } catch (error) {
236
- const accepted = await this.input.workQueue.complete({
237
- workId: item.workId,
238
- status: 'failed',
239
- error: toWorkerErrorPayload(error),
240
- }, this.buildCompleteScope(item));
261
+ let result: ToolExecutionResult;
262
+ const disposers: Array<() => Promise<void>> = [];
263
+ try {
264
+ const handler = this.input.toolRegistry.getTool(item.toolName);
265
+ const execution = await this.executeHandler(handler, item, disposers);
266
+ result = toToolExecutionResult(item.workId, handler, execution);
267
+ } catch (error) {
268
+ const accepted = await this.input.workQueue.complete({
269
+ workId: item.workId,
270
+ status: 'failed',
271
+ error: toWorkerErrorPayload(error),
272
+ }, this.buildCompleteScope(item));
273
+ if (!accepted) {
274
+ throw new Error(`work complete 未被接受:${item.workId}`);
275
+ }
276
+ return;
277
+ } finally {
278
+ await Promise.all(disposers.map(async (dispose) => {
279
+ await dispose().catch(() => undefined);
280
+ }));
281
+ }
282
+
283
+ const accepted = await this.input.workQueue.complete(result, this.buildCompleteScope(item));
241
284
  if (!accepted) {
242
285
  throw new Error(`work complete 未被接受:${item.workId}`);
243
286
  }
244
- return;
245
287
  } finally {
246
- await Promise.all(disposers.map(async (dispose) => {
247
- await dispose().catch(() => undefined);
248
- }));
288
+ renewController.abort();
289
+ await renewal;
249
290
  }
291
+ }
250
292
 
251
- const accepted = await this.input.workQueue.complete(result, this.buildCompleteScope(item));
252
- if (!accepted) {
253
- throw new Error(`work complete 未被接受:${item.workId}`);
293
+ private async renewLeaseUntilStopped(item: ToolWorkItem, signal: AbortSignal): Promise<void> {
294
+ const leaseId = item.leaseId;
295
+ if (!leaseId) {
296
+ return;
297
+ }
298
+ const intervalMs = normalizePositiveInteger(
299
+ this.input.leaseRenewIntervalMs,
300
+ DEFAULT_WORK_LEASE_RENEW_INTERVAL_MS,
301
+ );
302
+ while (!signal.aborted) {
303
+ await delay(intervalMs, signal);
304
+ if (signal.aborted) {
305
+ return;
306
+ }
307
+ try {
308
+ const renewed = await this.input.workQueue.renew({
309
+ workId: item.workId,
310
+ leaseId,
311
+ appId: this.input.appId,
312
+ environmentId: this.input.environmentId,
313
+ executionMode: this.input.executionMode ?? item.executionMode ?? 'self_hosted',
314
+ });
315
+ if (!renewed) {
316
+ return;
317
+ }
318
+ } catch (error) {
319
+ try {
320
+ await this.input.onError?.(error);
321
+ } catch {
322
+ // 观测回调失败不能终止仍持有 lease 的业务 handler;下一周期继续 renew。
323
+ }
324
+ }
254
325
  }
255
326
  }
256
327
 
@@ -393,11 +464,15 @@ export class EnvironmentWorker {
393
464
  }
394
465
 
395
466
  private buildCompleteScope(item: ToolWorkItem) {
467
+ if (!item.leaseId) {
468
+ throw new Error(`claimed work item 缺少 leaseId:${item.workId}`);
469
+ }
396
470
  return {
397
471
  appId: this.input.appId,
398
472
  environmentId: this.input.environmentId,
399
473
  executionMode: this.input.executionMode ?? item.executionMode ?? 'self_hosted',
400
- };
474
+ leaseId: item.leaseId,
475
+ } satisfies CompleteWorkItemScope;
401
476
  }
402
477
  }
403
478
 
@@ -498,7 +573,28 @@ export async function runEnvironmentWorkerLoop(input: RunEnvironmentWorkerLoopIn
498
573
  const reclaimOlderThanMs = input.reclaimOlderThanMs ?? DEFAULT_RECLAIM_OLDER_THAN_MS;
499
574
  const reclaimIntervalMs = normalizeNonNegativeInteger(input.reclaimIntervalMs, DEFAULT_RECLAIM_INTERVAL_MS);
500
575
  const blockMs = normalizeNonNegativeInteger(input.blockMs, DEFAULT_CLAIM_BLOCK_MS);
576
+ const maxConcurrency = normalizePositiveInteger(input.maxConcurrency, 1);
577
+ const leaseRenewIntervalMs = normalizePositiveInteger(
578
+ input.leaseRenewIntervalMs,
579
+ DEFAULT_WORK_LEASE_RENEW_INTERVAL_MS,
580
+ );
581
+ if (leaseRenewIntervalMs >= reclaimOlderThanMs) {
582
+ throw new Error('leaseRenewIntervalMs 必须小于 reclaimOlderThanMs');
583
+ }
501
584
  let nextReclaimAtMs = 0;
585
+ const active = new Set<Promise<void>>();
586
+
587
+ const startWork = (item: ToolWorkItem) => {
588
+ let tracked: Promise<void>;
589
+ tracked = worker.handleItem(item)
590
+ .catch(async (error) => {
591
+ await input.onError?.(error);
592
+ })
593
+ .finally(() => {
594
+ active.delete(tracked);
595
+ });
596
+ active.add(tracked);
597
+ };
502
598
 
503
599
  try {
504
600
  while (!input.abortSignal?.aborted) {
@@ -517,9 +613,22 @@ export async function runEnvironmentWorkerLoop(input: RunEnvironmentWorkerLoopIn
517
613
  await input.sandboxManager.sweepIdle(input.sandboxIdleTtlMs);
518
614
  }
519
615
 
520
- const drained = await worker.drainOnce({ blockMs });
521
- if (drained === 0) {
522
- await delay(idleDelayMs, input.abortSignal);
616
+ const availableSlots = maxConcurrency - active.size;
617
+ if (availableSlots <= 0) {
618
+ await waitForActiveWorkOrDeadline(active, nextReclaimAtMs, input.abortSignal);
619
+ continue;
620
+ }
621
+
622
+ const items = await worker.claim(availableSlots, { blockMs });
623
+ for (const item of items) {
624
+ startWork(item);
625
+ }
626
+ if (items.length === 0) {
627
+ if (active.size > 0) {
628
+ await waitForActiveWorkOrDelay(active, idleDelayMs, input.abortSignal);
629
+ } else {
630
+ await delay(idleDelayMs, input.abortSignal);
631
+ }
523
632
  }
524
633
  } catch (error) {
525
634
  await input.onError?.(error);
@@ -527,6 +636,7 @@ export async function runEnvironmentWorkerLoop(input: RunEnvironmentWorkerLoopIn
527
636
  }
528
637
  }
529
638
  } finally {
639
+ await Promise.allSettled(active);
530
640
  await input.sandboxManager?.disposeAll();
531
641
  }
532
642
  }
@@ -621,10 +731,25 @@ export class HttpWorkQueueClient implements WorkQueue {
621
731
  return items.map(parseToolWorkItem);
622
732
  }
623
733
 
624
- public async complete(result: ToolExecutionResult): Promise<boolean> {
734
+ public async renew(input: RenewWorkItemLeaseInput): Promise<boolean> {
735
+ this.assertScope(input.appId, input.environmentId);
736
+ const data = await this.requestJson('/work/renew', {
737
+ method: 'POST',
738
+ signal: this.input.signal,
739
+ body: JSON.stringify({ workId: input.workId, leaseId: input.leaseId }),
740
+ });
741
+ const record = expectRecord(data, 'renew 响应缺少 data');
742
+ return record.renewed === true;
743
+ }
744
+
745
+ public async complete(result: ToolExecutionResult, scope?: CompleteWorkItemScope): Promise<boolean> {
746
+ if (!scope?.leaseId) {
747
+ throw new Error(`work complete 缺少 leaseId:${result.workId}`);
748
+ }
749
+ this.assertScope(scope.appId, scope.environmentId);
625
750
  const data = await this.requestJson('/work/complete', {
626
751
  method: 'POST',
627
- body: JSON.stringify(result),
752
+ body: JSON.stringify({ ...result, leaseId: scope.leaseId }),
628
753
  });
629
754
  const record = expectRecord(data, 'complete 响应缺少 data');
630
755
  if (record.accepted !== true) {
@@ -1096,10 +1221,35 @@ function delay(ms: number, signal?: AbortSignal): Promise<void> {
1096
1221
  }
1097
1222
 
1098
1223
  return new Promise((resolve) => {
1099
- const timer = setTimeout(resolve, ms);
1100
- signal?.addEventListener('abort', () => {
1224
+ const finish = () => {
1101
1225
  clearTimeout(timer);
1226
+ signal?.removeEventListener('abort', finish);
1102
1227
  resolve();
1103
- }, { once: true });
1228
+ };
1229
+ const timer = setTimeout(finish, ms);
1230
+ signal?.addEventListener('abort', finish, { once: true });
1104
1231
  });
1105
1232
  }
1233
+
1234
+ function waitForActiveWorkOrDeadline(
1235
+ active: Set<Promise<void>>,
1236
+ deadlineMs: number,
1237
+ signal?: AbortSignal,
1238
+ ): Promise<void> {
1239
+ const waitMs = Math.max(1, deadlineMs - Date.now());
1240
+ return Promise.race([
1241
+ ...active,
1242
+ delay(waitMs, signal),
1243
+ ]).then(() => undefined);
1244
+ }
1245
+
1246
+ function waitForActiveWorkOrDelay(
1247
+ active: Set<Promise<void>>,
1248
+ delayMs: number,
1249
+ signal?: AbortSignal,
1250
+ ): Promise<void> {
1251
+ return Promise.race([
1252
+ ...active,
1253
+ delay(delayMs, signal),
1254
+ ]).then(() => undefined);
1255
+ }