agent-gear 2.3.0 → 2.5.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 (93) hide show
  1. package/AGENTS.md +74 -3
  2. package/dist/cloud-host/supervisor.d.ts +92 -0
  3. package/dist/cloud-host/supervisor.d.ts.map +1 -0
  4. package/dist/cloud-host/supervisor.js +216 -0
  5. package/dist/cloud-host/supervisor.js.map +1 -0
  6. package/dist/cloud-host/supervisor.test.d.ts +2 -0
  7. package/dist/cloud-host/supervisor.test.d.ts.map +1 -0
  8. package/dist/cloud-host/supervisor.test.js +296 -0
  9. package/dist/cloud-host/supervisor.test.js.map +1 -0
  10. package/dist/commands/harness.d.ts +17 -0
  11. package/dist/commands/harness.d.ts.map +1 -0
  12. package/dist/commands/harness.js +35 -0
  13. package/dist/commands/harness.js.map +1 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +10 -3
  16. package/dist/config.js.map +1 -1
  17. package/dist/harness/env.d.ts +36 -0
  18. package/dist/harness/env.d.ts.map +1 -0
  19. package/dist/harness/env.js +78 -0
  20. package/dist/harness/env.js.map +1 -0
  21. package/dist/harness/env.test.d.ts +2 -0
  22. package/dist/harness/env.test.d.ts.map +1 -0
  23. package/dist/harness/env.test.js +70 -0
  24. package/dist/harness/env.test.js.map +1 -0
  25. package/dist/harness/opencode-cli.d.ts +24 -0
  26. package/dist/harness/opencode-cli.d.ts.map +1 -0
  27. package/dist/harness/opencode-cli.js +74 -0
  28. package/dist/harness/opencode-cli.js.map +1 -0
  29. package/dist/harness/opencode-cli.test.d.ts +2 -0
  30. package/dist/harness/opencode-cli.test.d.ts.map +1 -0
  31. package/dist/harness/opencode-cli.test.js +62 -0
  32. package/dist/harness/opencode-cli.test.js.map +1 -0
  33. package/dist/harness/opencode-config.atomic-failure.test.d.ts +2 -0
  34. package/dist/harness/opencode-config.atomic-failure.test.d.ts.map +1 -0
  35. package/dist/harness/opencode-config.atomic-failure.test.js +35 -0
  36. package/dist/harness/opencode-config.atomic-failure.test.js.map +1 -0
  37. package/dist/harness/opencode-config.d.ts +54 -0
  38. package/dist/harness/opencode-config.d.ts.map +1 -0
  39. package/dist/harness/opencode-config.js +130 -0
  40. package/dist/harness/opencode-config.js.map +1 -0
  41. package/dist/harness/opencode-config.test.d.ts +2 -0
  42. package/dist/harness/opencode-config.test.d.ts.map +1 -0
  43. package/dist/harness/opencode-config.test.js +183 -0
  44. package/dist/harness/opencode-config.test.js.map +1 -0
  45. package/dist/harness/prepare.d.ts +34 -0
  46. package/dist/harness/prepare.d.ts.map +1 -0
  47. package/dist/harness/prepare.js +46 -0
  48. package/dist/harness/prepare.js.map +1 -0
  49. package/dist/harness/prepare.test.d.ts +2 -0
  50. package/dist/harness/prepare.test.d.ts.map +1 -0
  51. package/dist/harness/prepare.test.js +96 -0
  52. package/dist/harness/prepare.test.js.map +1 -0
  53. package/dist/heartbeat.d.ts.map +1 -1
  54. package/dist/heartbeat.js +2 -0
  55. package/dist/heartbeat.js.map +1 -1
  56. package/dist/index.js +21 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/socketio-client.d.ts +62 -3
  59. package/dist/socketio-client.d.ts.map +1 -1
  60. package/dist/socketio-client.js +155 -16
  61. package/dist/socketio-client.js.map +1 -1
  62. package/dist/socketio-client.test.d.ts +2 -0
  63. package/dist/socketio-client.test.d.ts.map +1 -0
  64. package/dist/socketio-client.test.js +132 -0
  65. package/dist/socketio-client.test.js.map +1 -0
  66. package/dist/workload-executor.d.ts +31 -2
  67. package/dist/workload-executor.d.ts.map +1 -1
  68. package/dist/workload-executor.js +89 -16
  69. package/dist/workload-executor.js.map +1 -1
  70. package/dist/workload-executor.test.js +180 -1
  71. package/dist/workload-executor.test.js.map +1 -1
  72. package/package.json +2 -1
  73. package/src/cloud-host/supervisor.test.ts +367 -0
  74. package/src/cloud-host/supervisor.ts +289 -0
  75. package/src/commands/harness.ts +44 -0
  76. package/src/config.ts +9 -3
  77. package/src/harness/env.test.ts +79 -0
  78. package/src/harness/env.ts +89 -0
  79. package/src/harness/opencode-cli.test.ts +82 -0
  80. package/src/harness/opencode-cli.ts +99 -0
  81. package/src/harness/opencode-config.atomic-failure.test.ts +51 -0
  82. package/src/harness/opencode-config.test.ts +238 -0
  83. package/src/harness/opencode-config.ts +176 -0
  84. package/src/harness/prepare.test.ts +117 -0
  85. package/src/harness/prepare.ts +69 -0
  86. package/src/heartbeat.ts +2 -0
  87. package/src/index.ts +22 -0
  88. package/src/socketio-client.test.ts +155 -0
  89. package/src/socketio-client.ts +204 -18
  90. package/src/workload-executor.test.ts +237 -0
  91. package/src/workload-executor.ts +115 -17
  92. package/tests/commands/harness.test.ts +90 -0
  93. package/tests/socketio-client.test.ts +140 -2
@@ -7,6 +7,9 @@ import {
7
7
  MAX_OUTPUT_BYTES,
8
8
  MAX_PENDING_RESULTS,
9
9
  EMPTY_SCRIPT_MESSAGE,
10
+ redactSecrets,
11
+ collectSecretValues,
12
+ REDACTED,
10
13
  type WorkloadResult,
11
14
  } from './workload-executor.js';
12
15
 
@@ -439,3 +442,237 @@ describe('WorkloadExecutor', () => {
439
442
  await batch;
440
443
  }, 10000);
441
444
  });
445
+
446
+ describe('WorkloadExecutor required short-circuit (T-WE-REQ)', () => {
447
+ it('T-WE-REQ14: a required init item that fails stops subsequent items', async () => {
448
+ const executor = new WorkloadExecutor();
449
+ const { results, sink } = collect();
450
+
451
+ await executor.runBatch(
452
+ {
453
+ phase: 'init',
454
+ items: [
455
+ { logId: 'req', workloadName: 'Req', script: 'exit 3', timeoutMs: 5000, required: true },
456
+ { logId: 'after', workloadName: 'After', script: 'echo should-not-run', timeoutMs: 5000 },
457
+ ],
458
+ },
459
+ sink,
460
+ );
461
+
462
+ expect(results.map((r) => r.logId)).toEqual(['req']);
463
+ expect(results[0].status).toBe('failed');
464
+ expect(results[0].exitCode).toBe(3);
465
+ });
466
+
467
+ it('T-WE-REQ15: a required item as the last item failing ends the batch normally', async () => {
468
+ const executor = new WorkloadExecutor();
469
+ const { results, sink } = collect();
470
+
471
+ await executor.runBatch(
472
+ {
473
+ phase: 'init',
474
+ items: [
475
+ { logId: 'ok', workloadName: 'Ok', script: 'echo ok', timeoutMs: 5000 },
476
+ { logId: 'req', workloadName: 'Req', script: 'exit 1', timeoutMs: 5000, required: true },
477
+ ],
478
+ },
479
+ sink,
480
+ );
481
+
482
+ expect(results.map((r) => r.logId)).toEqual(['ok', 'req']);
483
+ expect(results.map((r) => r.status)).toEqual(['success', 'failed']);
484
+ });
485
+
486
+ it('T-WE-REQ16: a non-required failure continues subsequent items', async () => {
487
+ const executor = new WorkloadExecutor();
488
+ const { results, sink } = collect();
489
+
490
+ await executor.runBatch(
491
+ {
492
+ phase: 'init',
493
+ items: [
494
+ { logId: 'bad', workloadName: 'Bad', script: 'exit 2', timeoutMs: 5000, required: false },
495
+ { logId: 'good', workloadName: 'Good', script: 'echo good', timeoutMs: 5000 },
496
+ ],
497
+ },
498
+ sink,
499
+ );
500
+
501
+ expect(results.map((r) => r.logId)).toEqual(['bad', 'good']);
502
+ expect(results.map((r) => r.status)).toEqual(['failed', 'success']);
503
+ });
504
+
505
+ it('T-WE-REQ17: missing required field behaves exactly like the legacy executor', async () => {
506
+ const executor = new WorkloadExecutor();
507
+ const { results, sink } = collect();
508
+
509
+ await executor.runBatch(
510
+ {
511
+ phase: 'init',
512
+ items: [
513
+ { logId: 'bad', workloadName: 'Bad', script: 'exit 1', timeoutMs: 5000 },
514
+ { logId: 'good', workloadName: 'Good', script: 'echo good', timeoutMs: 5000 },
515
+ ],
516
+ },
517
+ sink,
518
+ );
519
+
520
+ expect(results.map((r) => r.logId)).toEqual(['bad', 'good']);
521
+ expect(results.map((r) => r.status)).toEqual(['failed', 'success']);
522
+ });
523
+
524
+ it('T-WE-REQ18: a required post item failure does not short-circuit (post is never must)', async () => {
525
+ const executor = new WorkloadExecutor();
526
+ const { results, sink } = collect();
527
+
528
+ await executor.runBatch(
529
+ {
530
+ phase: 'post',
531
+ items: [
532
+ { logId: 'bad', workloadName: 'Bad', script: 'exit 1', timeoutMs: 5000, required: true },
533
+ { logId: 'good', workloadName: 'Good', script: 'echo cleaned', timeoutMs: 5000 },
534
+ ],
535
+ },
536
+ sink,
537
+ );
538
+
539
+ expect(results.map((r) => r.logId)).toEqual(['bad', 'good']);
540
+ expect(results.map((r) => r.status)).toEqual(['failed', 'success']);
541
+ });
542
+
543
+ it('T-WE-REQ14: a required init item timing out stops subsequent items', async () => {
544
+ const executor = new WorkloadExecutor();
545
+ const { results, sink } = collect();
546
+
547
+ await executor.runBatch(
548
+ {
549
+ phase: 'init',
550
+ items: [
551
+ { logId: 'req', workloadName: 'Req', script: 'sleep 5', timeoutMs: 300, required: true },
552
+ { logId: 'after', workloadName: 'After', script: 'echo should-not-run', timeoutMs: 5000 },
553
+ ],
554
+ },
555
+ sink,
556
+ );
557
+
558
+ expect(results.map((r) => r.logId)).toEqual(['req']);
559
+ expect(results[0].status).toBe('timeout');
560
+ }, 10000);
561
+ });
562
+
563
+ describe('WorkloadExecutor container env + secret redaction (T-AG13/T-AG14)', () => {
564
+ it('T-AG14: injects container env into every item and lets item params win on WORKLOAD_PARAM_*', async () => {
565
+ const executor = new WorkloadExecutor();
566
+ const { results, sink } = collect();
567
+
568
+ await executor.runBatch(
569
+ {
570
+ phase: 'init',
571
+ env: {
572
+ ZAG_MODEL_IDS: '["model-a","model-b"]',
573
+ ZAG_DEFAULT_WORKSPACE: '/root/workspace',
574
+ ZAG_USER_ID: 'user-1',
575
+ WORKLOAD_PARAM_VERSION: 'from-container-env',
576
+ },
577
+ items: [
578
+ {
579
+ logId: 'first',
580
+ workloadName: 'First',
581
+ script: `printf 'IDS=%s\\n' "$ZAG_MODEL_IDS"; printf 'WS=%s\\n' "$ZAG_DEFAULT_WORKSPACE"; printf 'USER=%s\\n' "$ZAG_USER_ID"; printf 'V=%s\\n' "$WORKLOAD_PARAM_VERSION"`,
582
+ timeoutMs: 5000,
583
+ params: { version: 'from-item-param' },
584
+ },
585
+ {
586
+ logId: 'second',
587
+ workloadName: 'Second',
588
+ script: `printf 'IDS=%s\\n' "$ZAG_MODEL_IDS"; printf 'V=%s\\n' "$WORKLOAD_PARAM_VERSION"`,
589
+ timeoutMs: 5000,
590
+ },
591
+ ],
592
+ },
593
+ sink,
594
+ );
595
+
596
+ expect(results.map((r) => r.logId)).toEqual(['first', 'second']);
597
+ expect(results[0].log).toContain('IDS=["model-a","model-b"]');
598
+ expect(results[0].log).toContain('WS=/root/workspace');
599
+ expect(results[0].log).toContain('USER=user-1');
600
+ // item 参数优先于容器 env 的同名 WORKLOAD_PARAM_*
601
+ expect(results[0].log).toContain('V=from-item-param');
602
+ expect(results[1].log).toContain('V=from-container-env');
603
+ });
604
+
605
+ it('T-AG14b: keeps legacy behavior when the payload has no container env', async () => {
606
+ const executor = new WorkloadExecutor();
607
+ const { results, sink } = collect();
608
+
609
+ await executor.runBatch(
610
+ {
611
+ phase: 'init',
612
+ items: [
613
+ {
614
+ logId: 'legacy',
615
+ workloadName: 'Legacy',
616
+ script: `printf 'ENV=%s\\n' "\${ZAG_MODEL_IDS-unset}"; printf 'P=%s\\n' "$WORKLOAD_PARAMS"`,
617
+ timeoutMs: 5000,
618
+ },
619
+ ],
620
+ },
621
+ sink,
622
+ );
623
+
624
+ expect(results[0].status).toBe('success');
625
+ expect(results[0].log).toContain('ENV=unset');
626
+ expect(results[0].log).toContain('P={}');
627
+ });
628
+
629
+ it('T-AG13: redacts secret values from script output (container env + process env)', async () => {
630
+ const executor = new WorkloadExecutor();
631
+ const { results, sink } = collect();
632
+ const previousHostKey = process.env.ZAG_HOST_KEY;
633
+ process.env.ZAG_HOST_KEY = 'zag_k_process_secret';
634
+ try {
635
+ await executor.runBatch(
636
+ {
637
+ phase: 'init',
638
+ env: {
639
+ ZAG_MODEL_API_KEY: 'zag_sk_container_secret',
640
+ ZAG_CONTROLLER_KEY: 'controller-token-secret',
641
+ },
642
+ items: [
643
+ {
644
+ logId: 'leak',
645
+ workloadName: 'Leak',
646
+ script: `printf 'A=%s\\n' "$ZAG_MODEL_API_KEY"; printf 'B=%s\\n' "$ZAG_HOST_KEY"; printf 'C=%s\\n' "$ZAG_CONTROLLER_KEY"`,
647
+ timeoutMs: 5000,
648
+ },
649
+ ],
650
+ },
651
+ sink,
652
+ );
653
+ } finally {
654
+ if (previousHostKey === undefined) delete process.env.ZAG_HOST_KEY;
655
+ else process.env.ZAG_HOST_KEY = previousHostKey;
656
+ }
657
+
658
+ expect(results).toHaveLength(1);
659
+ const log = results[0].log;
660
+ expect(log).not.toContain('zag_sk_container_secret');
661
+ expect(log).not.toContain('zag_k_process_secret');
662
+ expect(log).not.toContain('controller-token-secret');
663
+ expect(log).toContain(`A=${REDACTED}`);
664
+ expect(log).toContain(`B=${REDACTED}`);
665
+ expect(log).toContain(`C=${REDACTED}`);
666
+ });
667
+
668
+ it('T-AG13b: redactSecrets handles multiple secrets and ignores empty values', () => {
669
+ const secrets = collectSecretValues(
670
+ { ZAG_MODEL_API_KEY: 'sk-1', ZAG_HOST_KEY: '', ZAG_CONTROLLER_KEY: 'ct-2' },
671
+ { ZAG_HOST_KEY: 'host-3' },
672
+ );
673
+ expect(secrets.sort()).toEqual(['ct-2', 'host-3', 'sk-1'].sort());
674
+
675
+ expect(redactSecrets('a sk-1 b ct-2 c host-3 d', secrets)).toBe(`a ${REDACTED} b ${REDACTED} c ${REDACTED} d`);
676
+ expect(redactSecrets('nothing to hide', secrets)).toBe('nothing to hide');
677
+ });
678
+ });
@@ -23,6 +23,11 @@ export interface WorkloadItem {
23
23
  script: string;
24
24
  /** 本次执行参数,键为参数标识;缺省 `{}`(等价旧行为)。 */
25
25
  params?: Record<string, unknown>;
26
+ /**
27
+ * 本次启动该条是否为「必须」项(仅 `phase=init` 有意义,缺省/非 true 按非必须)。
28
+ * 服务端为唯一裁决;worker 仅在该项失败/超时后省去后续无谓执行。
29
+ */
30
+ required?: boolean;
26
31
  /** 单条脚本超时(毫秒)。 */
27
32
  timeoutMs: number;
28
33
  }
@@ -34,6 +39,52 @@ export interface WorkloadRunPayload {
34
39
  items: WorkloadItem[];
35
40
  /** `post` 批次共享总预算(毫秒);`init` 不携带。 */
36
41
  budgetMs?: number;
42
+ /**
43
+ * 容器级环境变量(仅 `purpose=harness` 下发,如 `ZAG_MODEL_*` / `ZAG_HOST_*`)。
44
+ * 对批次内所有 item 生效;`item.params` 派生的 `WORKLOAD_PARAM_*` 同名时优先。
45
+ */
46
+ env?: Record<string, string>;
47
+ }
48
+
49
+ /** 需要在日志/错误/脚本输出中脱敏的环境变量名(契约 §2.9「云电脑初始化 env」)。 */
50
+ export const SECRET_ENV_KEYS = ['ZAG_MODEL_API_KEY', 'ZAG_HOST_KEY', 'ZAG_CONTROLLER_KEY'] as const;
51
+
52
+ /** 脱敏占位符。 */
53
+ export const REDACTED = '***';
54
+
55
+ /**
56
+ * 收集需要脱敏的 secret 值(仅非空值;调用方可叠加批次 `env` 与 `process.env`)。
57
+ */
58
+ export function collectSecretValues(...sources: Array<Record<string, string | undefined> | undefined>): string[] {
59
+ const values = new Set<string>();
60
+ for (const source of sources) {
61
+ if (!source) continue;
62
+ for (const key of SECRET_ENV_KEYS) {
63
+ const value = source[key];
64
+ if (typeof value === 'string' && value.length > 0) values.add(value);
65
+ }
66
+ }
67
+ // 长值优先替换,避免短 secret 是长 secret 的子串时留下残片。
68
+ return [...values].sort((a, b) => b.length - a.length);
69
+ }
70
+
71
+ /** 将文本中出现的 secret 值替换为 `***`(日志、错误与脚本输出统一口径)。 */
72
+ export function redactSecrets(text: string, secrets: Iterable<string>): string {
73
+ let result = text;
74
+ for (const secret of secrets) {
75
+ if (!secret) continue;
76
+ result = result.split(secret).join(REDACTED);
77
+ }
78
+ return result;
79
+ }
80
+
81
+ export interface WorkloadBatchOutcome {
82
+ /** 批次是否被新批次/销毁流程打断(打断时不得视为初始化完成)。 */
83
+ cancelled: boolean;
84
+ /** 已上报的失败/超时条目数(含 required 短路前的当前条)。 */
85
+ failedCount: number;
86
+ /** 本批条目总数。 */
87
+ total: number;
37
88
  }
38
89
 
39
90
  export type WorkloadStatus = 'success' | 'failed' | 'timeout';
@@ -107,6 +158,20 @@ function nowIso(): string {
107
158
  return new Date().toISOString();
108
159
  }
109
160
 
161
+ /**
162
+ * 归一容器级 `env`:只接受字符串键值(服务端报文里 `ZAG_*` 均为字符串),
163
+ * 非对象/数组一律视为未下发(普通容器行为不变)。
164
+ */
165
+ function normalizeContainerEnv(env: Record<string, string> | undefined): Record<string, string> {
166
+ if (!env || typeof env !== 'object' || Array.isArray(env)) return {};
167
+ const normalized: Record<string, string> = {};
168
+ for (const [key, value] of Object.entries(env)) {
169
+ if (!key || typeof value !== 'string') continue;
170
+ normalized[key] = value;
171
+ }
172
+ return normalized;
173
+ }
174
+
110
175
  /** 单个参数值 → 环境变量字符串(契约 §2.9「参数注入约定」)。 */
111
176
  function encodeParamValue(value: unknown): string {
112
177
  if (typeof value === 'string') return value;
@@ -118,17 +183,23 @@ function encodeParamValue(value: unknown): string {
118
183
  }
119
184
 
120
185
  /**
121
- * 由 `process.env` 派生单条 item 的执行环境:
186
+ * 由 `process.env` + 容器级 `env` 派生单条 item 的执行环境:
187
+ * - 容器级 `env`(harness 下发的 `ZAG_*`)先合并,对批次所有 item 生效;
122
188
  * - `WORKLOAD_PARAMS`:完整参数 JSON 字符串(缺省 `{}`);
123
- * - `WORKLOAD_PARAM_<KEY_UPPER>`:单值编码(字符串原样、布尔 `"true"`/`"false"`、多选 JSON 数组字符串)。
189
+ * - `WORKLOAD_PARAM_<KEY_UPPER>`:单值编码(字符串原样、布尔 `"true"`/`"false"`、多选 JSON 数组字符串),
190
+ * 同名时**覆盖**容器 `env`(item 参数优先)。
124
191
  *
125
- * 每次 spawn 都从当前 `process.env` 重新派生,参数 env 仅对当前 item 生效、不污染后续脚本;
192
+ * 每次 spawn 都重新派生,参数 env 仅对当前 item 生效、不污染后续脚本;
126
193
  * 不修改脚本文本、不做字符串替换(避免注入)。
127
194
  */
128
- function buildWorkloadEnv(params: Record<string, unknown> | undefined): NodeJS.ProcessEnv {
195
+ function buildWorkloadEnv(
196
+ params: Record<string, unknown> | undefined,
197
+ containerEnv: Record<string, string> | undefined,
198
+ ): NodeJS.ProcessEnv {
129
199
  const normalized = params && typeof params === 'object' && !Array.isArray(params) ? params : {};
130
200
  const env: NodeJS.ProcessEnv = {
131
201
  ...process.env,
202
+ ...normalizeContainerEnv(containerEnv),
132
203
  WORKLOAD_PARAMS: JSON.stringify(normalized),
133
204
  };
134
205
  for (const [key, value] of Object.entries(normalized)) {
@@ -144,7 +215,8 @@ function buildWorkloadEnv(params: Record<string, unknown> | undefined): NodeJS.P
144
215
  * - 按 `items` 顺序**串行**执行 `bash -c <script>`(`cwd=os.homedir()`,继承进程环境并按 item 注入
145
216
  * `WORKLOAD_PARAMS` / `WORKLOAD_PARAM_*`,stdout/stderr 合并捕获);
146
217
  * - 单条超时或收到新批次时终止整个进程组(`detached: true` + `kill(-pid)`);
147
- * - 非零退出回报 `failed` + `exitCode`,不影响后续条目;
218
+ * - 非零退出回报 `failed` + `exitCode`,不影响后续条目;**例外**:`init` 阶段的必须项
219
+ * (`item.required === true`)失败/超时后不再启动后续条目(服务端仍会中断剩余记录并注销容器);
148
220
  * - `post` 批次共享 `budgetMs`:剩余预算耗尽时后续条目直接回报 `timeout`(不启动进程);
149
221
  * - 未被服务端确认的结果保存在内存(上限 50 条)供重连重放。
150
222
  *
@@ -152,7 +224,7 @@ function buildWorkloadEnv(params: Record<string, unknown> | undefined): NodeJS.P
152
224
  */
153
225
  export class WorkloadExecutor {
154
226
  private active: ActiveRun | null = null;
155
- private activePromise: Promise<void> | null = null;
227
+ private activePromise: Promise<WorkloadBatchOutcome> | null = null;
156
228
  private pendingResults: WorkloadResult[] = [];
157
229
 
158
230
  /** 当前是否有批次正在执行。 */
@@ -201,7 +273,7 @@ export class WorkloadExecutor {
201
273
  /**
202
274
  * 执行一个批次。若已有批次在跑,先终止其进程组并停止推进,再执行新批次。
203
275
  */
204
- runBatch(payload: WorkloadRunPayload, onResult: WorkloadResultSink): Promise<void> {
276
+ runBatch(payload: WorkloadRunPayload, onResult: WorkloadResultSink): Promise<WorkloadBatchOutcome> {
205
277
  this.interruptActive();
206
278
 
207
279
  const run: ActiveRun = { cancelled: false, child: null };
@@ -221,36 +293,57 @@ export class WorkloadExecutor {
221
293
  payload: WorkloadRunPayload,
222
294
  onResult: WorkloadResultSink,
223
295
  run: ActiveRun,
224
- ): Promise<void> {
296
+ ): Promise<WorkloadBatchOutcome> {
225
297
  const items = Array.isArray(payload?.items) ? payload.items : [];
226
- if (items.length === 0) return;
298
+ const total = items.length;
299
+ let failedCount = 0;
300
+ const outcome = (): WorkloadBatchOutcome => ({
301
+ cancelled: run.cancelled,
302
+ failedCount,
303
+ total,
304
+ });
305
+ if (items.length === 0) return outcome();
227
306
 
228
307
  const budgetMs = typeof payload.budgetMs === 'number' && payload.budgetMs > 0 ? payload.budgetMs : null;
229
308
  const batchStart = Date.now();
309
+ const phase = payload.phase;
310
+ const containerEnv = normalizeContainerEnv(payload.env);
311
+ const secrets = collectSecretValues(process.env, containerEnv);
230
312
 
231
313
  for (const item of items) {
232
- if (run.cancelled) return;
314
+ if (run.cancelled) return outcome();
315
+
316
+ // 必须项短路(省时优化,服务端仍为唯一裁决):init 阶段的必须项失败/超时后,
317
+ // 上报本条结果即结束本批,不再启动后续条目。post 批次不参与必须判定。
318
+ const shortCircuitOnFailure = phase === 'init' && item.required === true;
319
+ const reportThenMaybeStop = (result: WorkloadResult): boolean => {
320
+ if (result.status !== 'success') failedCount += 1;
321
+ const redactedLog = redactSecrets(result.log, secrets);
322
+ this.report(onResult, redactedLog === result.log ? result : { ...result, log: redactedLog });
323
+ return shortCircuitOnFailure && (result.status === 'failed' || result.status === 'timeout');
324
+ };
233
325
 
234
326
  let effectiveTimeoutMs = item.timeoutMs;
235
327
  if (budgetMs !== null) {
236
328
  const remaining = budgetMs - (Date.now() - batchStart);
237
329
  if (remaining <= 0) {
238
330
  // 预算耗尽:后续条目不再启动进程,直接回报 timeout
239
- this.report(onResult, this.emptyResult(item.logId, 'timeout', ''));
331
+ if (reportThenMaybeStop(this.emptyResult(item.logId, 'timeout', ''))) return outcome();
240
332
  continue;
241
333
  }
242
334
  effectiveTimeoutMs = Math.min(item.timeoutMs, remaining);
243
335
  }
244
336
 
245
337
  if (!item.script || item.script.trim().length === 0) {
246
- this.report(onResult, this.emptyResult(item.logId, 'failed', EMPTY_SCRIPT_MESSAGE));
338
+ if (reportThenMaybeStop(this.emptyResult(item.logId, 'failed', EMPTY_SCRIPT_MESSAGE))) return outcome();
247
339
  continue;
248
340
  }
249
341
 
250
- const outcome = await this.executeItem(item, effectiveTimeoutMs, run);
251
- if (outcome.cancelled) return;
252
- if (outcome.result) this.report(onResult, outcome.result);
342
+ const itemOutcome = await this.executeItem(item, effectiveTimeoutMs, run, containerEnv);
343
+ if (itemOutcome.cancelled) return outcome();
344
+ if (itemOutcome.result && reportThenMaybeStop(itemOutcome.result)) return outcome();
253
345
  }
346
+ return outcome();
254
347
  }
255
348
 
256
349
  private report(onResult: WorkloadResultSink, result: WorkloadResult): void {
@@ -266,7 +359,12 @@ export class WorkloadExecutor {
266
359
  return { logId, status, exitCode: null, log, startedAt: now, finishedAt: now };
267
360
  }
268
361
 
269
- private executeItem(item: WorkloadItem, timeoutMs: number, run: ActiveRun): Promise<ItemOutcome> {
362
+ private executeItem(
363
+ item: WorkloadItem,
364
+ timeoutMs: number,
365
+ run: ActiveRun,
366
+ containerEnv: Record<string, string>,
367
+ ): Promise<ItemOutcome> {
270
368
  return new Promise<ItemOutcome>((resolve) => {
271
369
  if (run.cancelled) {
272
370
  resolve({ cancelled: true });
@@ -279,7 +377,7 @@ export class WorkloadExecutor {
279
377
  child = spawn('bash', ['-c', item.script], {
280
378
  cwd: homedir(),
281
379
  detached: true,
282
- env: buildWorkloadEnv(item.params),
380
+ env: buildWorkloadEnv(item.params, containerEnv),
283
381
  });
284
382
  } catch (error) {
285
383
  resolve({
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { Command } from 'commander';
3
+ import { registerHarnessCommands } from '../../src/commands/harness.js';
4
+ import type { HarnessPrepareResult } from '../../src/harness/prepare.js';
5
+
6
+ function buildProgram(deps: Parameters<typeof registerHarnessCommands>[1] = {}) {
7
+ const program = new Command();
8
+ program.name('agent-gear').exitOverride();
9
+ registerHarnessCommands(program, deps);
10
+ return program;
11
+ }
12
+
13
+ const SUCCESS_RESULT: HarnessPrepareResult = {
14
+ opencodeVersion: '1.2.3',
15
+ env: {
16
+ modelBaseUrl: 'http://admin-api:8002/v1',
17
+ defaultWorkspace: '/root/workspace',
18
+ modelCount: 2,
19
+ },
20
+ config: {
21
+ path: '/root/.config/opencode/opencode.jsonc',
22
+ created: true,
23
+ modelCount: 2,
24
+ },
25
+ };
26
+
27
+ describe('agent-gear harness command (T-AG15)', () => {
28
+ const originalExitCode = process.exitCode;
29
+
30
+ beforeEach(() => {
31
+ vi.spyOn(console, 'log').mockImplementation(() => {});
32
+ vi.spyOn(console, 'error').mockImplementation(() => {});
33
+ process.exitCode = undefined;
34
+ });
35
+
36
+ afterEach(() => {
37
+ vi.restoreAllMocks();
38
+ process.exitCode = originalExitCode;
39
+ delete process.env.ZAG_MODEL_API_KEY;
40
+ });
41
+
42
+ it('shows prepare in `agent-gear harness --help`', async () => {
43
+ let output = '';
44
+ const program = buildProgram();
45
+ program.configureOutput({ writeOut: (text) => { output += text; }, writeErr: () => {} });
46
+
47
+ await program.parseAsync(['node', 'agent-gear', 'harness', '--help']).catch(() => {});
48
+
49
+ expect(output).toContain('prepare');
50
+ });
51
+
52
+ it('rejects unknown subcommands with a non-zero exit code', async () => {
53
+ const program = buildProgram();
54
+ program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
55
+
56
+ await expect(
57
+ program.parseAsync(['node', 'agent-gear', 'harness', 'bogus']),
58
+ ).rejects.toMatchObject({ exitCode: 1 });
59
+ });
60
+
61
+ it('prints a secret-free summary on success', async () => {
62
+ const prepare = vi.fn(async () => SUCCESS_RESULT);
63
+ const program = buildProgram({ prepare });
64
+
65
+ await program.parseAsync(['node', 'agent-gear', 'harness', 'prepare']);
66
+
67
+ expect(prepare).toHaveBeenCalledTimes(1);
68
+ expect(process.exitCode).toBeUndefined();
69
+ const output = vi.mocked(console.log).mock.calls.flat().join(' ');
70
+ expect(output).toContain('OpenCode harness prepared');
71
+ expect(output).toContain('version=1.2.3');
72
+ expect(output).not.toContain('zag_sk_');
73
+ });
74
+
75
+ it('exits non-zero and redacts secrets when prepare fails', async () => {
76
+ process.env.ZAG_MODEL_API_KEY = 'zag_sk_super_secret';
77
+ const prepare = vi.fn(async () => {
78
+ throw new Error('failed with key zag_sk_super_secret');
79
+ });
80
+ const program = buildProgram({ prepare });
81
+
82
+ await program.parseAsync(['node', 'agent-gear', 'harness', 'prepare']);
83
+
84
+ expect(process.exitCode).toBe(1);
85
+ const errors = vi.mocked(console.error).mock.calls.flat().join(' ');
86
+ expect(errors).toContain('harness prepare failed');
87
+ expect(errors).not.toContain('zag_sk_super_secret');
88
+ expect(errors).toContain('***');
89
+ });
90
+ });