@principles/pd-cli 1.101.0 → 1.103.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 (39) hide show
  1. package/dist/commands/diagnose.js +27 -27
  2. package/dist/commands/diagnose.js.map +1 -1
  3. package/dist/commands/pain-retry.d.ts.map +1 -1
  4. package/dist/commands/pain-retry.js +22 -27
  5. package/dist/commands/pain-retry.js.map +1 -1
  6. package/dist/commands/runtime-internalization-integrity.d.ts.map +1 -1
  7. package/dist/commands/runtime-internalization-integrity.js +40 -1
  8. package/dist/commands/runtime-internalization-integrity.js.map +1 -1
  9. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
  10. package/dist/commands/runtime-internalization-run-once.js +11 -9
  11. package/dist/commands/runtime-internalization-run-once.js.map +1 -1
  12. package/dist/commands/runtime.d.ts +1 -1
  13. package/dist/commands/runtime.d.ts.map +1 -1
  14. package/dist/commands/runtime.js +92 -25
  15. package/dist/commands/runtime.js.map +1 -1
  16. package/dist/services/mainline-snapshot-assembler.d.ts +35 -0
  17. package/dist/services/mainline-snapshot-assembler.d.ts.map +1 -0
  18. package/dist/services/mainline-snapshot-assembler.js +399 -0
  19. package/dist/services/mainline-snapshot-assembler.js.map +1 -0
  20. package/dist/services/resolve-runtime-from-pd-config.d.ts +59 -0
  21. package/dist/services/resolve-runtime-from-pd-config.d.ts.map +1 -0
  22. package/dist/services/resolve-runtime-from-pd-config.js +96 -0
  23. package/dist/services/resolve-runtime-from-pd-config.js.map +1 -0
  24. package/package.json +1 -1
  25. package/src/commands/diagnose.ts +26 -26
  26. package/src/commands/pain-retry.ts +21 -25
  27. package/src/commands/runtime-internalization-integrity.ts +40 -1
  28. package/src/commands/runtime-internalization-run-once.ts +10 -9
  29. package/src/commands/runtime.ts +96 -24
  30. package/src/services/mainline-snapshot-assembler.ts +544 -0
  31. package/src/services/resolve-runtime-from-pd-config.ts +142 -0
  32. package/tests/commands/console-launcher-edge-cases.test.ts +14 -47
  33. package/tests/commands/diagnose.test.ts +91 -39
  34. package/tests/commands/pain-retry.test.ts +130 -15
  35. package/tests/commands/pri-393-runtime-config-unification.test.ts +284 -0
  36. package/tests/commands/runtime-internalization-integrity.test.ts +37 -0
  37. package/tests/commands/runtime-internalization-run-once.test.ts +59 -53
  38. package/tests/commands/runtime.test.ts +124 -1
  39. package/tests/services/mainline-snapshot-assembler.test.ts +425 -0
@@ -55,65 +55,32 @@ describe('Port competition scenarios', () => {
55
55
  });
56
56
 
57
57
  it('findAvailablePort skips occupied ports in sequence', async () => {
58
- // Occupy 3 consecutive ports using dynamically allocated base port
59
- const servers: net.Server[] = [];
60
- // First, get a dynamic port to use as base
61
- const probeServer = net.createServer();
62
- const basePort = await new Promise<number>((resolve) => {
63
- probeServer.listen(0, '127.0.0.1', () => {
64
- const addr = probeServer.address();
65
- if (typeof addr === 'object' && addr) resolve(addr.port);
66
- });
67
- });
68
- await new Promise<void>((resolve) => probeServer.close(() => resolve()));
69
-
70
- for (let i = 0; i < 3; i++) {
71
- const s = net.createServer();
72
- await new Promise<void>((resolve) => {
73
- s.listen(basePort + i, '127.0.0.1', () => resolve());
74
- });
75
- servers.push(s);
76
- }
77
-
58
+ // Use mock to simulate 3 consecutive occupied ports avoids flaky real-network I/O
59
+ const basePort = 49200;
60
+ (globalThis as any).__mockIsPortInUse = async (_host: string, port: number) => {
61
+ return port >= basePort && port <= basePort + 2;
62
+ };
78
63
  try {
79
- // Should skip all 3 and return the next free one
80
64
  const port = await findAvailablePort('127.0.0.1', basePort, 5);
65
+ // Should skip basePort, basePort+1, basePort+2 and return basePort+3
81
66
  expect(port).toBe(basePort + 3);
82
67
  } finally {
83
- for (const s of servers) {
84
- await new Promise<void>((resolve) => s.close(() => resolve()));
85
- }
68
+ delete (globalThis as any).__mockIsPortInUse;
86
69
  }
87
70
  });
88
71
 
89
72
  it('returns null when all fallback ports are exhausted', async () => {
90
- // Occupy a range of ports using dynamically allocated base port
91
- const servers: net.Server[] = [];
92
- const probeServer = net.createServer();
93
- const basePort = await new Promise<number>((resolve) => {
94
- probeServer.listen(0, '127.0.0.1', () => {
95
- const addr = probeServer.address();
96
- if (typeof addr === 'object' && addr) resolve(addr.port);
97
- });
98
- });
99
- await new Promise<void>((resolve) => probeServer.close(() => resolve()));
100
-
101
- for (let i = 0; i < 10; i++) {
102
- const s = net.createServer();
103
- await new Promise<void>((resolve) => {
104
- s.listen(basePort + i, '127.0.0.1', () => resolve());
105
- });
106
- servers.push(s);
107
- }
108
-
73
+ // Use mock to simulate all ports occupied avoids flaky real-network I/O
74
+ const basePort = 49300;
75
+ (globalThis as any).__mockIsPortInUse = async (_host: string, port: number) => {
76
+ return port >= basePort && port <= basePort + 9;
77
+ };
109
78
  try {
110
- // With limit=5, should return null after exhausting fallback
79
+ // With limit=5, all 5 candidates are occupied → null
111
80
  const port = await findAvailablePort('127.0.0.1', basePort, 5);
112
81
  expect(port).toBeNull();
113
82
  } finally {
114
- for (const s of servers) {
115
- await new Promise<void>((resolve) => s.close(() => resolve()));
116
- }
83
+ delete (globalThis as any).__mockIsPortInUse;
117
84
  }
118
85
  });
119
86
  });
@@ -41,6 +41,23 @@ const { MockPrincipleTreeLedgerAdapter } = vi.hoisted(() => {
41
41
  return { MockPrincipleTreeLedgerAdapter };
42
42
  });
43
43
 
44
+ const { mockResolveRuntimeFromPdConfig } = vi.hoisted(() => {
45
+ const mockResolveRuntimeFromPdConfig = vi.fn().mockReturnValue({
46
+ result: {
47
+ runtimeKind: 'pi-ai',
48
+ provider: 'test-provider',
49
+ model: 'test-model',
50
+ apiKeyEnv: 'TEST_KEY',
51
+ timeoutMs: 300000,
52
+ agentId: 'main',
53
+ },
54
+ legacyWarnings: [],
55
+ configSource: '.pd/config.yaml',
56
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
57
+ });
58
+ return { mockResolveRuntimeFromPdConfig };
59
+ });
60
+
44
61
  vi.mock('../../src/resolve-workspace.js', () => ({
45
62
  resolveWorkspaceDir: vi.fn().mockReturnValue('/tmp/fake-workspace'),
46
63
  }));
@@ -122,6 +139,10 @@ vi.mock('../../src/services/pd-config-loader.js', () => ({
122
139
  computeFlagsFromLoadResult: vi.fn().mockReturnValue({}),
123
140
  }));
124
141
 
142
+ vi.mock('../../src/services/resolve-runtime-from-pd-config.js', () => ({
143
+ resolveRuntimeFromPdConfig: mockResolveRuntimeFromPdConfig,
144
+ }));
145
+
125
146
  import { handleDiagnoseRun, handleDiagnoseStatus, type DiagnoseRunOptions } from '../../src/commands/diagnose.js';
126
147
 
127
148
  const SUCCEEDED_RESULT = {
@@ -177,14 +198,18 @@ describe('pd diagnose run --runtime routing', () => {
177
198
  exitSpy.mockRestore();
178
199
  });
179
200
 
180
- it('HG-03: --runtime openclaw-cli without mode (no file config) fails via resolveRuntimeConfig', async () => {
181
- mockResolveRuntimeConfig.mockReturnValueOnce({
182
- ok: false,
183
- reason: 'missing_openclaw_mode',
184
- message: 'runtimeKind is openclaw-cli but no mode specified',
185
- nextAction: 'Provide exactly one mode',
201
+ it('HG-03: --runtime openclaw-cli without mode (no file config) fails via resolveRuntimeFromPdConfig', async () => {
202
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
203
+ result: {
204
+ ok: false,
205
+ reason: 'missing_openclaw_mode',
206
+ message: 'runtimeKind is openclaw-cli but no mode specified',
207
+ nextAction: 'Provide exactly one mode',
208
+ },
209
+ legacyWarnings: [],
210
+ configSource: '.pd/config.yaml',
211
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
186
212
  });
187
- mockIsRuntimeConfigError.mockReturnValueOnce(true);
188
213
 
189
214
  const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
190
215
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
@@ -196,7 +221,7 @@ describe('pd diagnose run --runtime routing', () => {
196
221
  json: false,
197
222
  } as DiagnoseRunOptions);
198
223
 
199
- expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('no mode specified'));
224
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('no mode resolved'));
200
225
  expect(exitSpy).toHaveBeenCalledWith(1);
201
226
 
202
227
  consoleErrorSpy.mockRestore();
@@ -226,13 +251,17 @@ describe('pd diagnose run --runtime routing', () => {
226
251
  });
227
252
 
228
253
  it('DPB-09: openclaw-cli with file config openclawMode succeeds without CLI flag', async () => {
229
- mockResolveRuntimeConfig.mockReturnValueOnce({
230
- runtimeKind: 'openclaw-cli',
231
- openclawMode: 'local',
232
- timeoutMs: 300000,
233
- agentId: 'main',
254
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
255
+ result: {
256
+ runtimeKind: 'openclaw-cli',
257
+ openclawMode: 'local',
258
+ timeoutMs: 300000,
259
+ agentId: 'main',
260
+ },
261
+ legacyWarnings: [],
262
+ configSource: '.pd/config.yaml',
263
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
234
264
  });
235
- mockIsRuntimeConfigError.mockReturnValueOnce(false);
236
265
 
237
266
  const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
238
267
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
@@ -250,14 +279,18 @@ describe('pd diagnose run --runtime routing', () => {
250
279
  exitSpy.mockRestore();
251
280
  });
252
281
 
253
- it('DPB-09: openclaw-cli flag overrides file config mode', async () => {
254
- mockResolveRuntimeConfig.mockReturnValueOnce({
255
- runtimeKind: 'openclaw-cli',
256
- openclawMode: 'gateway',
257
- timeoutMs: 300000,
258
- agentId: 'main',
282
+ it('DPB-09: openclaw-cli flag overrides file config mode (config=gateway, flag=local → runtimeMode=local)', async () => {
283
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
284
+ result: {
285
+ runtimeKind: 'openclaw-cli',
286
+ openclawMode: 'gateway',
287
+ timeoutMs: 300000,
288
+ agentId: 'main',
289
+ },
290
+ legacyWarnings: [],
291
+ configSource: '.pd/config.yaml',
292
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
259
293
  });
260
- mockIsRuntimeConfigError.mockReturnValueOnce(false);
261
294
 
262
295
  const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
263
296
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
@@ -270,6 +303,13 @@ describe('pd diagnose run --runtime routing', () => {
270
303
  json: false,
271
304
  } as DiagnoseRunOptions);
272
305
 
306
+ // Flag override: config says gateway, flag says local → adapter gets local
307
+ const OpenClawCliMock = vi.mocked(
308
+ await import('@principles/core/runtime-v2').then(m => m.OpenClawCliRuntimeAdapter),
309
+ );
310
+ expect(OpenClawCliMock).toHaveBeenCalledWith(
311
+ expect.objectContaining({ runtimeMode: 'local' }),
312
+ );
273
313
  expect(exitSpy).not.toHaveBeenCalledWith(1);
274
314
 
275
315
  consoleSpy.mockRestore();
@@ -277,13 +317,17 @@ describe('pd diagnose run --runtime routing', () => {
277
317
  });
278
318
 
279
319
  it('DPB-09: openclaw-cli missing mode (--json) outputs JSON error', async () => {
280
- mockResolveRuntimeConfig.mockReturnValueOnce({
281
- ok: false,
282
- reason: 'missing_openclaw_mode',
283
- message: 'runtimeKind is openclaw-cli but no mode specified',
284
- nextAction: 'Provide exactly one mode',
320
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
321
+ result: {
322
+ runtimeKind: 'openclaw-cli',
323
+ openclawMode: undefined,
324
+ timeoutMs: 300000,
325
+ agentId: 'main',
326
+ },
327
+ legacyWarnings: [],
328
+ configSource: '.pd/config.yaml',
329
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
285
330
  });
286
- mockIsRuntimeConfigError.mockReturnValueOnce(true);
287
331
 
288
332
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
289
333
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
@@ -323,13 +367,17 @@ describe('pd diagnose run --runtime routing', () => {
323
367
  });
324
368
 
325
369
  it('DPB-09: openclaw-cli --openclaw-gateway constructs adapter with runtimeMode=gateway', async () => {
326
- mockResolveRuntimeConfig.mockReturnValueOnce({
327
- runtimeKind: 'openclaw-cli',
328
- openclawMode: 'gateway',
329
- timeoutMs: 300000,
330
- agentId: 'main',
370
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
371
+ result: {
372
+ runtimeKind: 'openclaw-cli',
373
+ openclawMode: 'gateway',
374
+ timeoutMs: 300000,
375
+ agentId: 'main',
376
+ },
377
+ legacyWarnings: [],
378
+ configSource: '.pd/config.yaml',
379
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
331
380
  });
332
- mockIsRuntimeConfigError.mockReturnValueOnce(false);
333
381
 
334
382
  const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
335
383
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
@@ -355,13 +403,17 @@ describe('pd diagnose run --runtime routing', () => {
355
403
  });
356
404
 
357
405
  it('DPB-09: openclaw-cli --openclaw-local constructs adapter with runtimeMode=local', async () => {
358
- mockResolveRuntimeConfig.mockReturnValueOnce({
359
- runtimeKind: 'openclaw-cli',
360
- openclawMode: 'local',
361
- timeoutMs: 300000,
362
- agentId: 'main',
406
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
407
+ result: {
408
+ runtimeKind: 'openclaw-cli',
409
+ openclawMode: 'local',
410
+ timeoutMs: 300000,
411
+ agentId: 'main',
412
+ },
413
+ legacyWarnings: [],
414
+ configSource: '.pd/config.yaml',
415
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
363
416
  });
364
- mockIsRuntimeConfigError.mockReturnValueOnce(false);
365
417
 
366
418
  const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
367
419
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
@@ -55,7 +55,7 @@ const { MockPrincipleTreeLedgerAdapter } = vi.hoisted(() => {
55
55
  return { MockPrincipleTreeLedgerAdapter };
56
56
  });
57
57
 
58
- const { mockRun, mockResolveRuntimeConfig } = vi.hoisted(() => {
58
+ const { mockRun, mockResolveRuntimeConfig, mockResolveRuntimeFromPdConfig } = vi.hoisted(() => {
59
59
  const mockRun = vi.fn().mockResolvedValue({
60
60
  status: 'succeeded',
61
61
  taskId: 'diagnosis_test-pain-1',
@@ -70,7 +70,20 @@ const { mockRun, mockResolveRuntimeConfig } = vi.hoisted(() => {
70
70
  timeoutMs: 300000,
71
71
  agentId: 'main',
72
72
  });
73
- return { mockRun, mockResolveRuntimeConfig };
73
+ const mockResolveRuntimeFromPdConfig = vi.fn().mockReturnValue({
74
+ result: {
75
+ runtimeKind: 'pi-ai',
76
+ provider: 'test-provider',
77
+ model: 'test-model',
78
+ apiKeyEnv: 'TEST_KEY',
79
+ timeoutMs: 300000,
80
+ agentId: 'main',
81
+ },
82
+ legacyWarnings: [],
83
+ configSource: '.pd/config.yaml',
84
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
85
+ });
86
+ return { mockRun, mockResolveRuntimeConfig, mockResolveRuntimeFromPdConfig };
74
87
  });
75
88
 
76
89
  vi.mock('../../src/resolve-workspace.js', () => ({
@@ -128,6 +141,10 @@ vi.mock('../../src/config-reader.js', () => ({
128
141
  readOutputLanguageFromWorkspace: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
129
142
  }));
130
143
 
144
+ vi.mock('../../src/services/resolve-runtime-from-pd-config.js', () => ({
145
+ resolveRuntimeFromPdConfig: mockResolveRuntimeFromPdConfig,
146
+ }));
147
+
131
148
  import { handlePainRetry } from '../../src/commands/pain-retry.js';
132
149
 
133
150
  // ── Test Data ──────────────────────────────────────────────────────────────────
@@ -201,6 +218,19 @@ describe('pd pain retry — validation and error paths', () => {
201
218
  timeoutMs: 300000,
202
219
  agentId: 'main',
203
220
  });
221
+ mockResolveRuntimeFromPdConfig.mockReturnValue({
222
+ result: {
223
+ runtimeKind: 'pi-ai',
224
+ provider: 'test-provider',
225
+ model: 'test-model',
226
+ apiKeyEnv: 'TEST_KEY',
227
+ timeoutMs: 300000,
228
+ agentId: 'main',
229
+ },
230
+ legacyWarnings: [],
231
+ configSource: '.pd/config.yaml',
232
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
233
+ });
204
234
  mockGetTask.mockResolvedValue(null);
205
235
  mockGetCandidatesByTaskId.mockResolvedValue([]);
206
236
  mockUpdateCandidateStatus.mockResolvedValue(undefined);
@@ -342,11 +372,16 @@ describe('pd pain retry — validation and error paths', () => {
342
372
 
343
373
  it('RETRY-05a: missing --runtime and no config — refused with reason + nextAction (JSON)', async () => {
344
374
  mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
345
- // Make resolveRuntimeConfig return an error so no runtime is resolved from config
346
- mockResolveRuntimeConfig.mockReturnValueOnce({
347
- reason: 'config_not_found',
348
- message: 'No workflows.yaml found',
349
- nextAction: 'Create workflows.yaml or pass --runtime',
375
+ // PRI-393: resolveRuntimeFromPdConfig returns error no runtime resolved
376
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
377
+ result: {
378
+ reason: 'config_not_found',
379
+ message: 'No .pd/config.yaml found',
380
+ nextAction: 'Create .pd/config.yaml or pass --runtime',
381
+ },
382
+ legacyWarnings: [],
383
+ configSource: '.pd/config.yaml',
384
+ configLoadResult: { ok: false, effective: {}, defaults: {}, legacyFilesDetected: [] },
350
385
  });
351
386
 
352
387
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
@@ -404,14 +439,19 @@ describe('pd pain retry — validation and error paths', () => {
404
439
 
405
440
  it('RETRY-05c: blank provider/model/apiKeyEnv — refused with missing_required_config', async () => {
406
441
  mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
407
- // Config returns blank strings for provider/model/apiKeyEnv
408
- mockResolveRuntimeConfig.mockReturnValueOnce({
409
- runtimeKind: 'pi-ai',
410
- provider: '',
411
- model: ' ',
412
- apiKeyEnv: '',
413
- timeoutMs: 300000,
414
- agentId: 'main',
442
+ // PRI-393: resolveRuntimeFromPdConfig returns config with blank strings
443
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
444
+ result: {
445
+ runtimeKind: 'pi-ai',
446
+ provider: '',
447
+ model: ' ',
448
+ apiKeyEnv: '',
449
+ timeoutMs: 300000,
450
+ agentId: 'main',
451
+ },
452
+ legacyWarnings: [],
453
+ configSource: '.pd/config.yaml',
454
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
415
455
  });
416
456
 
417
457
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
@@ -440,6 +480,81 @@ describe('pd pain retry — validation and error paths', () => {
440
480
  logSpy.mockRestore();
441
481
  exitSpy.mockRestore();
442
482
  });
483
+
484
+ it('DPB-09: openclaw-cli flag overrides file config mode (config=gateway, flag=local → runtimeMode=local)', async () => {
485
+ mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
486
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
487
+ result: {
488
+ runtimeKind: 'openclaw-cli',
489
+ openclawMode: 'gateway',
490
+ timeoutMs: 300000,
491
+ agentId: 'main',
492
+ },
493
+ legacyWarnings: [],
494
+ configSource: '.pd/config.yaml',
495
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
496
+ });
497
+
498
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
499
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
500
+
501
+ await handlePainRetry({
502
+ painId: 'test-pain-1',
503
+ workspace: '/tmp/fake-workspace',
504
+ runtime: 'openclaw-cli',
505
+ openclawLocal: true,
506
+ json: true,
507
+ });
508
+
509
+ // Flag override: config says gateway, flag says local → adapter gets local
510
+ const OpenClawCliMock = vi.mocked(
511
+ await import('@principles/core/runtime-v2').then(m => m.OpenClawCliRuntimeAdapter),
512
+ );
513
+ expect(OpenClawCliMock).toHaveBeenCalledWith(
514
+ expect.objectContaining({ runtimeMode: 'local' }),
515
+ );
516
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
517
+
518
+ logSpy.mockRestore();
519
+ exitSpy.mockRestore();
520
+ });
521
+
522
+ it('DPB-09: openclaw-cli flag overrides file config mode (config=local, flag=gateway → runtimeMode=gateway)', async () => {
523
+ mockGetTask.mockResolvedValue(RETRY_WAIT_TASK);
524
+ mockResolveRuntimeFromPdConfig.mockReturnValueOnce({
525
+ result: {
526
+ runtimeKind: 'openclaw-cli',
527
+ openclawMode: 'local',
528
+ timeoutMs: 300000,
529
+ agentId: 'main',
530
+ },
531
+ legacyWarnings: [],
532
+ configSource: '.pd/config.yaml',
533
+ configLoadResult: { ok: true, effective: {}, defaults: {}, legacyFilesDetected: [] },
534
+ });
535
+
536
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
537
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
538
+
539
+ await handlePainRetry({
540
+ painId: 'test-pain-1',
541
+ workspace: '/tmp/fake-workspace',
542
+ runtime: 'openclaw-cli',
543
+ openclawGateway: true,
544
+ json: true,
545
+ });
546
+
547
+ const OpenClawCliMock = vi.mocked(
548
+ await import('@principles/core/runtime-v2').then(m => m.OpenClawCliRuntimeAdapter),
549
+ );
550
+ expect(OpenClawCliMock).toHaveBeenCalledWith(
551
+ expect.objectContaining({ runtimeMode: 'gateway' }),
552
+ );
553
+ expect(exitSpy).not.toHaveBeenCalledWith(1);
554
+
555
+ logSpy.mockRestore();
556
+ exitSpy.mockRestore();
557
+ });
443
558
  });
444
559
 
445
560
  describe('pd pain retry — success paths', () => {