@ct-agents/worker 0.1.5 → 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 +179 -34
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ct-agents/worker",
3
- "version": "0.1.5",
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.5",
16
- "@ct-agents/protocol": "0.1.5",
17
- "@ct-agents/prompts": "0.1.5",
18
- "@ct-agents/tools": "0.1.5"
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,6 +21,8 @@ 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';
@@ -93,7 +95,11 @@ export type EnvironmentWorkerInput = {
93
95
  resourceImpls?: EnvironmentWorkerResourceImplementations;
94
96
  platformResourceProxy?: EnvironmentWorkerPlatformResourceProxy;
95
97
  abortSignal?: AbortSignal;
96
- 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>;
97
103
  harness?: {
98
104
  id: string;
99
105
  version: number;
@@ -140,6 +146,7 @@ export type RunContextResolverWorkerLoopInput = ContextResolverWorkerInput & {
140
146
  export const DEFAULT_RECLAIM_OLDER_THAN_MS = 300_000;
141
147
  export const DEFAULT_RECLAIM_INTERVAL_MS = 60_000;
142
148
  export const DEFAULT_CLAIM_BLOCK_MS = 15_000;
149
+ export const DEFAULT_WORK_LEASE_RENEW_INTERVAL_MS = 60_000;
143
150
 
144
151
  const PLATFORM_PROXY_RESOURCE_SLOTS = new Set(['database', 'skills', 'memory']);
145
152
 
@@ -166,6 +173,13 @@ function normalizeNonNegativeInteger(value: number | undefined, fallback: number
166
173
  return Math.max(0, Math.trunc(value));
167
174
  }
168
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
+
169
183
  class PlatformBuiltinToolRegistry implements ToolRegistry {
170
184
  private readonly builtinHandlers = new Map<string, ToolHandler<unknown, unknown>>();
171
185
 
@@ -217,45 +231,97 @@ export class EnvironmentWorker {
217
231
  }
218
232
 
219
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[]> {
220
244
  const executionMode = this.input.executionMode ?? 'self_hosted';
221
- const items = await this.input.workQueue.claim({
245
+ return this.input.workQueue.claim({
222
246
  appId: this.input.appId,
223
247
  environmentId: this.input.environmentId,
224
248
  executionMode,
225
- max: this.input.maxBatchSize ?? 1,
249
+ max: normalizePositiveInteger(max, 1),
226
250
  blockMs: options.blockMs,
227
251
  });
228
-
229
- await Promise.all(items.map((item) => this.handleItem(item)));
230
- return items.length;
231
252
  }
232
253
 
233
- private async handleItem(item: ToolWorkItem): Promise<void> {
234
- let result: ToolExecutionResult;
235
- 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);
236
260
  try {
237
- const handler = this.input.toolRegistry.getTool(item.toolName);
238
- const execution = await this.executeHandler(handler, item, disposers);
239
- result = toToolExecutionResult(item.workId, handler, execution);
240
- } catch (error) {
241
- const accepted = await this.input.workQueue.complete({
242
- workId: item.workId,
243
- status: 'failed',
244
- error: toWorkerErrorPayload(error),
245
- }, 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));
246
284
  if (!accepted) {
247
285
  throw new Error(`work complete 未被接受:${item.workId}`);
248
286
  }
249
- return;
250
287
  } finally {
251
- await Promise.all(disposers.map(async (dispose) => {
252
- await dispose().catch(() => undefined);
253
- }));
288
+ renewController.abort();
289
+ await renewal;
254
290
  }
291
+ }
255
292
 
256
- const accepted = await this.input.workQueue.complete(result, this.buildCompleteScope(item));
257
- if (!accepted) {
258
- 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
+ }
259
325
  }
260
326
  }
261
327
 
@@ -398,11 +464,15 @@ export class EnvironmentWorker {
398
464
  }
399
465
 
400
466
  private buildCompleteScope(item: ToolWorkItem) {
467
+ if (!item.leaseId) {
468
+ throw new Error(`claimed work item 缺少 leaseId:${item.workId}`);
469
+ }
401
470
  return {
402
471
  appId: this.input.appId,
403
472
  environmentId: this.input.environmentId,
404
473
  executionMode: this.input.executionMode ?? item.executionMode ?? 'self_hosted',
405
- };
474
+ leaseId: item.leaseId,
475
+ } satisfies CompleteWorkItemScope;
406
476
  }
407
477
  }
408
478
 
@@ -503,7 +573,28 @@ export async function runEnvironmentWorkerLoop(input: RunEnvironmentWorkerLoopIn
503
573
  const reclaimOlderThanMs = input.reclaimOlderThanMs ?? DEFAULT_RECLAIM_OLDER_THAN_MS;
504
574
  const reclaimIntervalMs = normalizeNonNegativeInteger(input.reclaimIntervalMs, DEFAULT_RECLAIM_INTERVAL_MS);
505
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
+ }
506
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
+ };
507
598
 
508
599
  try {
509
600
  while (!input.abortSignal?.aborted) {
@@ -522,9 +613,22 @@ export async function runEnvironmentWorkerLoop(input: RunEnvironmentWorkerLoopIn
522
613
  await input.sandboxManager.sweepIdle(input.sandboxIdleTtlMs);
523
614
  }
524
615
 
525
- const drained = await worker.drainOnce({ blockMs });
526
- if (drained === 0) {
527
- 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
+ }
528
632
  }
529
633
  } catch (error) {
530
634
  await input.onError?.(error);
@@ -532,6 +636,7 @@ export async function runEnvironmentWorkerLoop(input: RunEnvironmentWorkerLoopIn
532
636
  }
533
637
  }
534
638
  } finally {
639
+ await Promise.allSettled(active);
535
640
  await input.sandboxManager?.disposeAll();
536
641
  }
537
642
  }
@@ -626,10 +731,25 @@ export class HttpWorkQueueClient implements WorkQueue {
626
731
  return items.map(parseToolWorkItem);
627
732
  }
628
733
 
629
- 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);
630
750
  const data = await this.requestJson('/work/complete', {
631
751
  method: 'POST',
632
- body: JSON.stringify(result),
752
+ body: JSON.stringify({ ...result, leaseId: scope.leaseId }),
633
753
  });
634
754
  const record = expectRecord(data, 'complete 响应缺少 data');
635
755
  if (record.accepted !== true) {
@@ -1101,10 +1221,35 @@ function delay(ms: number, signal?: AbortSignal): Promise<void> {
1101
1221
  }
1102
1222
 
1103
1223
  return new Promise((resolve) => {
1104
- const timer = setTimeout(resolve, ms);
1105
- signal?.addEventListener('abort', () => {
1224
+ const finish = () => {
1106
1225
  clearTimeout(timer);
1226
+ signal?.removeEventListener('abort', finish);
1107
1227
  resolve();
1108
- }, { once: true });
1228
+ };
1229
+ const timer = setTimeout(finish, ms);
1230
+ signal?.addEventListener('abort', finish, { once: true });
1109
1231
  });
1110
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
+ }