@muyichengshayu/promptx 0.2.13 → 0.2.15

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 (52) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/apps/server/src/agentSessionDiscovery.js +180 -7
  3. package/apps/web/dist/assets/{CodexSessionManagerDialog-Dic9kMHK.js → CodexSessionManagerDialog-y7O-JTxP.js} +1 -1
  4. package/apps/web/dist/assets/{TaskDiffReviewDialog-CKiZdXqi.js → TaskDiffReviewDialog-CTr_zoAn.js} +1 -1
  5. package/apps/web/dist/assets/{WorkbenchSettingsDialog-CP0z90bm.js → WorkbenchSettingsDialog-Bf2DCuN_.js} +1 -1
  6. package/apps/web/dist/assets/{WorkbenchView-D1oxqNr4.css → WorkbenchView-CK1snPBz.css} +1 -1
  7. package/apps/web/dist/assets/WorkbenchView-Gq3mmtsK.js +60 -0
  8. package/apps/web/dist/assets/index-Co1Ssha9.js +2 -0
  9. package/apps/web/dist/index.html +1 -1
  10. package/package.json +21 -14
  11. package/apps/runner/src/engines/claudeCodeRunner.test.js +0 -467
  12. package/apps/runner/src/engines/kimiCodeRunner.test.js +0 -127
  13. package/apps/runner/src/engines/openCodeRunner.test.js +0 -236
  14. package/apps/runner/src/engines/runnerContract.test.js +0 -449
  15. package/apps/runner/src/engines/shellRunner.test.js +0 -46
  16. package/apps/runner/src/runManager.test.js +0 -913
  17. package/apps/runner/src/serverClient.test.js +0 -93
  18. package/apps/server/src/agentSessionDiscovery.test.js +0 -186
  19. package/apps/server/src/appPaths.test.js +0 -52
  20. package/apps/server/src/assetRoutes.test.js +0 -168
  21. package/apps/server/src/codex.test.js +0 -518
  22. package/apps/server/src/codexRoutes.test.js +0 -376
  23. package/apps/server/src/codexRuns.test.js +0 -160
  24. package/apps/server/src/codexSessions.test.js +0 -369
  25. package/apps/server/src/db.test.js +0 -182
  26. package/apps/server/src/gitDiff.test.js +0 -542
  27. package/apps/server/src/gitDiffClient.test.js +0 -140
  28. package/apps/server/src/internalRoutes.test.js +0 -134
  29. package/apps/server/src/maintenance.test.js +0 -154
  30. package/apps/server/src/processControl.test.js +0 -147
  31. package/apps/server/src/relayClient.test.js +0 -478
  32. package/apps/server/src/relayConfig.test.js +0 -73
  33. package/apps/server/src/relayProtocol.test.js +0 -49
  34. package/apps/server/src/relayServer.test.js +0 -798
  35. package/apps/server/src/relayTenants.test.js +0 -137
  36. package/apps/server/src/relayUsageStore.test.js +0 -65
  37. package/apps/server/src/repository.test.js +0 -150
  38. package/apps/server/src/runDispatchService.test.js +0 -563
  39. package/apps/server/src/runEventIngest.test.js +0 -225
  40. package/apps/server/src/runRecovery.test.js +0 -73
  41. package/apps/server/src/runnerClient.test.js +0 -80
  42. package/apps/server/src/runnerDispatch.test.js +0 -136
  43. package/apps/server/src/systemConfig.test.js +0 -112
  44. package/apps/server/src/systemRoutes.test.js +0 -319
  45. package/apps/server/src/taskRoutes.test.js +0 -775
  46. package/apps/server/src/upload.test.js +0 -30
  47. package/apps/server/src/webAppRoutes.test.js +0 -67
  48. package/apps/server/src/workspaceFiles.test.js +0 -279
  49. package/apps/web/dist/assets/WorkbenchView-noayQwj4.js +0 -60
  50. package/apps/web/dist/assets/index-HLkdzIYF.js +0 -2
  51. package/packages/shared/src/dailyLogStream.test.js +0 -29
  52. package/packages/shared/src/shellCommands.test.js +0 -45
@@ -1,913 +0,0 @@
1
- import assert from 'node:assert/strict'
2
- import test from 'node:test'
3
- import { setTimeout as delay } from 'node:timers/promises'
4
- import { classifyStopTimeoutPhase, createRunManager } from './runManager.js'
5
-
6
- function createFakeServerClient() {
7
- return {
8
- events: [],
9
- statuses: [],
10
- async postEvents(items = []) {
11
- this.events.push(...items)
12
- return { ok: true }
13
- },
14
- async postStatus(payload = {}) {
15
- this.statuses.push(payload)
16
- return { ok: true }
17
- },
18
- }
19
- }
20
-
21
- function createDeferred() {
22
- let resolve
23
- let reject
24
- const promise = new Promise((nextResolve, nextReject) => {
25
- resolve = nextResolve
26
- reject = nextReject
27
- })
28
- return { promise, resolve, reject }
29
- }
30
-
31
- test('runManager.getRun 对不存在的 run 返回 null', () => {
32
- const runManager = createRunManager({
33
- serverClient: createFakeServerClient(),
34
- resolveRunner() {
35
- throw new Error('should not resolve runner')
36
- },
37
- })
38
-
39
- assert.equal(runManager.getRun('missing-run'), null)
40
- })
41
-
42
- test('runManager 可以驱动一个最小 fake runner 完成执行并推送状态和事件', async () => {
43
- const serverClient = createFakeServerClient()
44
- const runManager = createRunManager({
45
- serverClient,
46
- resolveRunner() {
47
- return {
48
- streamSessionPrompt(session, prompt, callbacks = {}) {
49
- callbacks.onEvent?.({ type: 'stdout', text: `${session.id}:${prompt}` })
50
- callbacks.onThreadStarted?.('thread-test-1')
51
- return {
52
- child: {
53
- pid: 4321,
54
- exitCode: 0,
55
- signalCode: null,
56
- },
57
- result: Promise.resolve({
58
- sessionId: session.id,
59
- threadId: 'thread-test-1',
60
- message: 'done',
61
- }),
62
- cancel() {},
63
- }
64
- },
65
- }
66
- },
67
- })
68
-
69
- const snapshot = await runManager.startRun({
70
- runId: 'run-1',
71
- taskSlug: 'task-1',
72
- sessionId: 'session-1',
73
- title: 'Session 1',
74
- engine: 'codex',
75
- cwd: process.cwd(),
76
- prompt: 'hello',
77
- })
78
-
79
- assert.equal(snapshot.runId, 'run-1')
80
- assert.ok(['starting', 'running'].includes(snapshot.status))
81
-
82
- await delay(20)
83
-
84
- const statuses = serverClient.statuses.map((item) => item.status)
85
- assert.ok(statuses.includes('queued'))
86
- assert.ok(statuses.includes('starting'))
87
- assert.ok(statuses.includes('running'))
88
- assert.ok(statuses.includes('completed'))
89
-
90
- const eventTypes = serverClient.events.map((item) => item.payload?.type || item.type)
91
- assert.ok(eventTypes.includes('session'))
92
- assert.ok(eventTypes.includes('stdout'))
93
- assert.ok(eventTypes.includes('session.updated'))
94
- assert.equal(runManager.getRun('run-1'), null)
95
- })
96
-
97
- test('runManager 会在 OpenCode 只于最终结果返回 threadId 时回写 session 身份', async () => {
98
- const serverClient = createFakeServerClient()
99
- const runManager = createRunManager({
100
- serverClient,
101
- resolveRunner() {
102
- return {
103
- streamSessionPrompt() {
104
- return {
105
- child: {
106
- pid: 4322,
107
- exitCode: 0,
108
- signalCode: null,
109
- },
110
- result: Promise.resolve({
111
- sessionId: 'session-opencode-1',
112
- threadId: 'ses_opencode_final_1',
113
- message: 'done',
114
- }),
115
- cancel() {},
116
- }
117
- },
118
- }
119
- },
120
- })
121
-
122
- await runManager.startRun({
123
- runId: 'run-opencode-1',
124
- taskSlug: 'task-opencode-1',
125
- sessionId: 'session-opencode-1',
126
- title: 'OpenCode Session',
127
- engine: 'opencode',
128
- cwd: process.cwd(),
129
- prompt: 'hello',
130
- })
131
-
132
- await delay(20)
133
-
134
- const completedStatus = [...serverClient.statuses].reverse().find((item) => item.status === 'completed')
135
- assert.equal(completedStatus?.session?.engineSessionId, 'ses_opencode_final_1')
136
- assert.equal(completedStatus?.session?.engineThreadId, 'ses_opencode_final_1')
137
- assert.equal(completedStatus?.session?.codexThreadId, '')
138
-
139
- const sessionUpdatedEvent = serverClient.events.find((item) => item.payload?.type === 'session.updated')
140
- assert.equal(sessionUpdatedEvent?.payload?.session?.engineSessionId, 'ses_opencode_final_1')
141
- })
142
-
143
- test('runManager.getDiagnostics 返回活跃 run、排队信息和统计信息', async () => {
144
- const runManager = createRunManager({
145
- serverClient: createFakeServerClient(),
146
- resolveRunner() {
147
- return {
148
- streamSessionPrompt(session) {
149
- return {
150
- child: {
151
- pid: 5678,
152
- exitCode: null,
153
- signalCode: null,
154
- },
155
- result: delay(40).then(() => ({
156
- sessionId: session.id,
157
- threadId: 'thread-diag-1',
158
- message: 'done',
159
- })),
160
- cancel() {},
161
- }
162
- },
163
- }
164
- },
165
- })
166
-
167
- await runManager.startRun({
168
- runId: 'run-diag-1',
169
- taskSlug: 'task-diag-1',
170
- sessionId: 'session-diag-1',
171
- title: 'Session Diag 1',
172
- engine: 'codex',
173
- cwd: process.cwd(),
174
- prompt: 'hello',
175
- })
176
-
177
- const diagnosticsWhileRunning = runManager.getDiagnostics()
178
- assert.equal(diagnosticsWhileRunning.activeRunCount, 1)
179
- assert.equal(diagnosticsWhileRunning.runningRunCount, 1)
180
- assert.equal(diagnosticsWhileRunning.trackedRunCount, 1)
181
- assert.equal(diagnosticsWhileRunning.queuedRunCount, 0)
182
- assert.equal(diagnosticsWhileRunning.metrics.totalStarted, 1)
183
- assert.equal(diagnosticsWhileRunning.activeRuns[0]?.runId, 'run-diag-1')
184
- assert.equal(diagnosticsWhileRunning.activeRuns[0]?.cwd, process.cwd())
185
- assert.equal(diagnosticsWhileRunning.config.maxConcurrentRuns, 3)
186
-
187
- await delay(70)
188
-
189
- const diagnosticsAfterComplete = runManager.getDiagnostics()
190
- assert.equal(diagnosticsAfterComplete.activeRunCount, 0)
191
- assert.equal(diagnosticsAfterComplete.runningRunCount, 0)
192
- assert.equal(diagnosticsAfterComplete.trackedRunCount, 0)
193
- assert.equal(diagnosticsAfterComplete.metrics.totalCompleted, 1)
194
- })
195
-
196
- test('runManager 会按全局并发上限排队,并在前序 run 完成后拉起后续 run', async () => {
197
- const serverClient = createFakeServerClient()
198
- const startOrder = []
199
- const completions = new Map()
200
- let pidSeed = 4000
201
-
202
- const runManager = createRunManager({
203
- serverClient,
204
- maxConcurrentRuns: 1,
205
- resolveRunner() {
206
- return {
207
- streamSessionPrompt(session) {
208
- startOrder.push(session.id)
209
- const deferred = createDeferred()
210
- completions.set(session.id, deferred)
211
- pidSeed += 1
212
- return {
213
- child: {
214
- pid: pidSeed,
215
- exitCode: 0,
216
- signalCode: null,
217
- },
218
- result: deferred.promise,
219
- cancel() {},
220
- }
221
- },
222
- }
223
- },
224
- })
225
-
226
- const firstRun = await runManager.startRun({
227
- runId: 'run-queue-1',
228
- taskSlug: 'task-queue',
229
- sessionId: 'session-queue-1',
230
- title: 'Queue 1',
231
- engine: 'codex',
232
- cwd: process.cwd(),
233
- prompt: 'hello-1',
234
- })
235
- const secondRun = await runManager.startRun({
236
- runId: 'run-queue-2',
237
- taskSlug: 'task-queue',
238
- sessionId: 'session-queue-2',
239
- title: 'Queue 2',
240
- engine: 'codex',
241
- cwd: process.cwd(),
242
- prompt: 'hello-2',
243
- })
244
-
245
- assert.ok(['starting', 'running'].includes(firstRun.status))
246
- assert.equal(secondRun.status, 'queued')
247
- assert.deepEqual(startOrder, ['session-queue-1'])
248
-
249
- const diagnosticsWhileQueued = runManager.getDiagnostics()
250
- assert.equal(diagnosticsWhileQueued.activeRunCount, 1)
251
- assert.equal(diagnosticsWhileQueued.runningRunCount, 1)
252
- assert.equal(diagnosticsWhileQueued.trackedRunCount, 2)
253
- assert.equal(diagnosticsWhileQueued.queuedRunCount, 1)
254
- assert.equal(diagnosticsWhileQueued.queuedRuns[0]?.runId, 'run-queue-2')
255
- assert.equal(diagnosticsWhileQueued.metrics.totalStarted, 1)
256
-
257
- completions.get('session-queue-1').resolve({
258
- sessionId: 'session-queue-1',
259
- threadId: 'thread-queue-1',
260
- message: 'done-1',
261
- })
262
- await delay(40)
263
-
264
- assert.deepEqual(startOrder, ['session-queue-1', 'session-queue-2'])
265
- const secondSnapshot = runManager.getRun('run-queue-2')
266
- assert.ok(secondSnapshot)
267
- assert.ok(['starting', 'running'].includes(secondSnapshot.status))
268
-
269
- completions.get('session-queue-2').resolve({
270
- sessionId: 'session-queue-2',
271
- threadId: 'thread-queue-2',
272
- message: 'done-2',
273
- })
274
- await delay(40)
275
-
276
- const diagnosticsAfterComplete = runManager.getDiagnostics()
277
- assert.equal(diagnosticsAfterComplete.activeRunCount, 0)
278
- assert.equal(diagnosticsAfterComplete.runningRunCount, 0)
279
- assert.equal(diagnosticsAfterComplete.trackedRunCount, 0)
280
- assert.equal(diagnosticsAfterComplete.queuedRunCount, 0)
281
- assert.equal(diagnosticsAfterComplete.metrics.totalStarted, 2)
282
- assert.equal(diagnosticsAfterComplete.metrics.totalCompleted, 2)
283
-
284
- const secondStatuses = serverClient.statuses
285
- .filter((item) => item.runId === 'run-queue-2')
286
- .map((item) => item.status)
287
- assert.deepEqual(secondStatuses.slice(0, 2), ['queued', 'starting'])
288
- })
289
-
290
- test('runManager 可以动态更新 maxConcurrentRuns 并立刻继续拉起排队 run', async () => {
291
- const startOrder = []
292
- const completions = new Map()
293
-
294
- const runManager = createRunManager({
295
- serverClient: createFakeServerClient(),
296
- maxConcurrentRuns: 1,
297
- resolveRunner() {
298
- return {
299
- streamSessionPrompt(session) {
300
- startOrder.push(session.id)
301
- const deferred = createDeferred()
302
- completions.set(session.id, deferred)
303
- return {
304
- child: {
305
- pid: 7100 + startOrder.length,
306
- exitCode: 0,
307
- signalCode: null,
308
- },
309
- result: deferred.promise,
310
- cancel() {},
311
- }
312
- },
313
- }
314
- },
315
- })
316
-
317
- await runManager.startRun({
318
- runId: 'run-update-config-1',
319
- taskSlug: 'task-update-config',
320
- sessionId: 'session-update-config-1',
321
- title: 'Update Config 1',
322
- engine: 'codex',
323
- cwd: process.cwd(),
324
- prompt: 'hello-1',
325
- })
326
- await runManager.startRun({
327
- runId: 'run-update-config-2',
328
- taskSlug: 'task-update-config',
329
- sessionId: 'session-update-config-2',
330
- title: 'Update Config 2',
331
- engine: 'codex',
332
- cwd: process.cwd(),
333
- prompt: 'hello-2',
334
- })
335
-
336
- assert.deepEqual(startOrder, ['session-update-config-1'])
337
- assert.equal(runManager.getDiagnostics().queuedRunCount, 1)
338
- assert.equal(runManager.getDiagnostics().trackedRunCount, 2)
339
-
340
- const config = await runManager.updateConfig({
341
- maxConcurrentRuns: 2,
342
- })
343
- await delay(40)
344
-
345
- assert.equal(config.maxConcurrentRuns, 2)
346
- assert.deepEqual(startOrder, ['session-update-config-1', 'session-update-config-2'])
347
-
348
- completions.get('session-update-config-1').resolve({
349
- sessionId: 'session-update-config-1',
350
- threadId: 'thread-update-config-1',
351
- message: 'done-1',
352
- })
353
- completions.get('session-update-config-2').resolve({
354
- sessionId: 'session-update-config-2',
355
- threadId: 'thread-update-config-2',
356
- message: 'done-2',
357
- })
358
- await delay(40)
359
-
360
- assert.equal(runManager.getDiagnostics().metrics.totalCompleted, 2)
361
- })
362
-
363
- test('runManager 会为 queued run 持续发送心跳,避免被误判为失联', async () => {
364
- const serverClient = createFakeServerClient()
365
- const firstCompletion = createDeferred()
366
-
367
- const runManager = createRunManager({
368
- serverClient,
369
- maxConcurrentRuns: 1,
370
- resolveRunner() {
371
- return {
372
- streamSessionPrompt(session) {
373
- return {
374
- child: {
375
- pid: session.id === 'session-queued-heartbeat-1' ? 7201 : 7202,
376
- exitCode: 0,
377
- signalCode: null,
378
- },
379
- result: firstCompletion.promise,
380
- cancel() {},
381
- }
382
- },
383
- }
384
- },
385
- })
386
-
387
- await runManager.startRun({
388
- runId: 'run-queued-heartbeat-1',
389
- taskSlug: 'task-queued-heartbeat',
390
- sessionId: 'session-queued-heartbeat-1',
391
- title: 'Queued Heartbeat 1',
392
- engine: 'codex',
393
- cwd: process.cwd(),
394
- prompt: 'hold',
395
- })
396
- await runManager.startRun({
397
- runId: 'run-queued-heartbeat-2',
398
- taskSlug: 'task-queued-heartbeat',
399
- sessionId: 'session-queued-heartbeat-2',
400
- title: 'Queued Heartbeat 2',
401
- engine: 'codex',
402
- cwd: process.cwd(),
403
- prompt: 'queued',
404
- })
405
-
406
- await delay(1100)
407
-
408
- const queuedStatuses = serverClient.statuses.filter((item) => item.runId === 'run-queued-heartbeat-2')
409
- const queuedHeartbeatCount = queuedStatuses.filter((item) => item.status === 'queued').length
410
- assert.equal(queuedHeartbeatCount >= 2, true)
411
-
412
- firstCompletion.resolve({
413
- sessionId: 'session-queued-heartbeat-1',
414
- threadId: 'thread-queued-heartbeat-1',
415
- message: 'done',
416
- })
417
- await delay(60)
418
- })
419
-
420
- test('runManager 会为长时间静默的 running run 补发可见进度事件', async () => {
421
- const serverClient = createFakeServerClient()
422
- const completion = createDeferred()
423
-
424
- const runManager = createRunManager({
425
- serverClient,
426
- heartbeatIntervalMs: 120,
427
- idleProgressEventMs: 180,
428
- idleProgressEventRepeatMs: 180,
429
- eventFlushIntervalMs: 30,
430
- resolveRunner() {
431
- return {
432
- streamSessionPrompt() {
433
- return {
434
- child: {
435
- pid: 7301,
436
- exitCode: 0,
437
- signalCode: null,
438
- },
439
- result: completion.promise,
440
- cancel() {},
441
- }
442
- },
443
- }
444
- },
445
- })
446
-
447
- await runManager.startRun({
448
- runId: 'run-idle-progress-1',
449
- taskSlug: 'task-idle-progress',
450
- sessionId: 'session-idle-progress-1',
451
- title: 'Idle Progress 1',
452
- engine: 'claude-code',
453
- cwd: process.cwd(),
454
- prompt: 'silent run',
455
- })
456
-
457
- await delay(420)
458
-
459
- const statusEvents = serverClient.events
460
- .map((item) => item.payload || {})
461
- .filter((payload) => payload.type === 'status')
462
-
463
- assert.equal(
464
- statusEvents.some((payload) => payload.messageKey === 'runner.status.thinking'),
465
- true
466
- )
467
-
468
- completion.resolve({
469
- sessionId: 'session-idle-progress-1',
470
- threadId: 'thread-idle-progress-1',
471
- message: 'done',
472
- })
473
- await delay(80)
474
- })
475
-
476
- test('runManager 可以直接停止尚未启动的 queued run', async () => {
477
- const serverClient = createFakeServerClient()
478
- const firstCompletion = createDeferred()
479
-
480
- const runManager = createRunManager({
481
- serverClient,
482
- maxConcurrentRuns: 1,
483
- resolveRunner() {
484
- return {
485
- streamSessionPrompt(session) {
486
- return {
487
- child: {
488
- pid: session.id === 'session-stop-1' ? 5011 : 5012,
489
- exitCode: 0,
490
- signalCode: null,
491
- },
492
- result: firstCompletion.promise,
493
- cancel() {},
494
- }
495
- },
496
- }
497
- },
498
- })
499
-
500
- await runManager.startRun({
501
- runId: 'run-stop-1',
502
- taskSlug: 'task-stop',
503
- sessionId: 'session-stop-1',
504
- title: 'Stop 1',
505
- engine: 'codex',
506
- cwd: process.cwd(),
507
- prompt: 'hold',
508
- })
509
- await runManager.startRun({
510
- runId: 'run-stop-2',
511
- taskSlug: 'task-stop',
512
- sessionId: 'session-stop-2',
513
- title: 'Stop 2',
514
- engine: 'codex',
515
- cwd: process.cwd(),
516
- prompt: 'queued',
517
- })
518
-
519
- const stoppedSnapshot = await runManager.stopRun('run-stop-2')
520
- assert.equal(stoppedSnapshot?.status, 'stopped')
521
- assert.equal(runManager.getRun('run-stop-2'), null)
522
-
523
- const diagnosticsAfterStop = runManager.getDiagnostics()
524
- assert.equal(diagnosticsAfterStop.activeRunCount, 1)
525
- assert.equal(diagnosticsAfterStop.runningRunCount, 1)
526
- assert.equal(diagnosticsAfterStop.trackedRunCount, 1)
527
- assert.equal(diagnosticsAfterStop.queuedRunCount, 0)
528
- assert.equal(diagnosticsAfterStop.metrics.totalStarted, 1)
529
- assert.equal(diagnosticsAfterStop.metrics.totalStopped, 1)
530
- assert.equal(diagnosticsAfterStop.metrics.stopReasons.queued_cancelled, 1)
531
-
532
- const secondStatuses = serverClient.statuses
533
- .filter((item) => item.runId === 'run-stop-2')
534
- .map((item) => item.status)
535
- assert.deepEqual(secondStatuses, ['queued', 'stopped'])
536
-
537
- firstCompletion.resolve({
538
- sessionId: 'session-stop-1',
539
- threadId: 'thread-stop-1',
540
- message: 'done',
541
- })
542
- await delay(40)
543
-
544
- const diagnosticsAfterComplete = runManager.getDiagnostics()
545
- assert.equal(diagnosticsAfterComplete.metrics.totalCompleted, 1)
546
- assert.equal(diagnosticsAfterComplete.metrics.totalStopped, 1)
547
- assert.equal(diagnosticsAfterComplete.metrics.stopReasons.queued_cancelled, 1)
548
- })
549
-
550
- test('runManager 会统计运行中 stop 的原因分类', async () => {
551
- const serverClient = createFakeServerClient()
552
- const completion = createDeferred()
553
-
554
- const runManager = createRunManager({
555
- serverClient,
556
- resolveRunner() {
557
- return {
558
- streamSessionPrompt() {
559
- return {
560
- child: {
561
- pid: 6011,
562
- exitCode: 0,
563
- signalCode: null,
564
- },
565
- result: completion.promise,
566
- cancel() {},
567
- }
568
- },
569
- }
570
- },
571
- })
572
-
573
- await runManager.startRun({
574
- runId: 'run-stop-reason-1',
575
- taskSlug: 'task-stop-reason',
576
- sessionId: 'session-stop-reason-1',
577
- title: 'Stop Reason 1',
578
- engine: 'codex',
579
- cwd: process.cwd(),
580
- prompt: 'stop me',
581
- })
582
-
583
- await delay(20)
584
- const stoppingSnapshot = await runManager.stopRun('run-stop-reason-1')
585
- assert.equal(stoppingSnapshot?.status, 'stopping')
586
-
587
- completion.resolve({
588
- sessionId: 'session-stop-reason-1',
589
- threadId: 'thread-stop-reason-1',
590
- message: 'cancelled',
591
- })
592
- await delay(40)
593
-
594
- const diagnostics = runManager.getDiagnostics()
595
- assert.equal(diagnostics.metrics.totalStopped, 1)
596
- assert.equal(diagnostics.metrics.stopReasons.user_requested, 1)
597
- assert.equal(diagnostics.metrics.stopReasons.user_requested_after_error, 0)
598
- assert.equal(diagnostics.metrics.stopReasons.stop_timeout, 0)
599
- })
600
-
601
- test('runManager 会把 stop 后的执行报错归类为 user_requested', async () => {
602
- const serverClient = createFakeServerClient()
603
- const completion = createDeferred()
604
-
605
- const runManager = createRunManager({
606
- serverClient,
607
- resolveRunner() {
608
- return {
609
- streamSessionPrompt() {
610
- return {
611
- child: {
612
- pid: 6111,
613
- exitCode: null,
614
- signalCode: null,
615
- },
616
- result: completion.promise,
617
- cancel() {},
618
- }
619
- },
620
- }
621
- },
622
- })
623
-
624
- await runManager.startRun({
625
- runId: 'run-stop-error-1',
626
- taskSlug: 'task-stop-error',
627
- sessionId: 'session-stop-error-1',
628
- title: 'Stop Error 1',
629
- engine: 'codex',
630
- cwd: process.cwd(),
631
- prompt: 'stop me with error',
632
- })
633
-
634
- await delay(20)
635
- await runManager.stopRun('run-stop-error-1')
636
- completion.reject(new Error('killed by stop'))
637
- await delay(40)
638
-
639
- const diagnostics = runManager.getDiagnostics()
640
- assert.equal(diagnostics.metrics.totalStopped, 1)
641
- assert.equal(diagnostics.metrics.stopReasons.user_requested, 1)
642
- assert.equal(diagnostics.metrics.stopReasons.user_requested_after_error, 0)
643
- })
644
-
645
- test('runManager 会统计 event flush 失败次数', async () => {
646
- const completion = createDeferred()
647
- const serverClient = {
648
- events: [],
649
- statuses: [],
650
- postEventAttempts: 0,
651
- async postEvents(items = []) {
652
- this.postEventAttempts += 1
653
- if (this.postEventAttempts === 1) {
654
- throw new Error('flush failed once')
655
- }
656
- this.events.push(...items)
657
- return { ok: true }
658
- },
659
- async postStatus(payload = {}) {
660
- this.statuses.push(payload)
661
- return { ok: true }
662
- },
663
- }
664
-
665
- const runManager = createRunManager({
666
- serverClient,
667
- logger: {
668
- error() {},
669
- },
670
- resolveRunner() {
671
- return {
672
- streamSessionPrompt(session, prompt, callbacks = {}) {
673
- callbacks.onEvent?.({ type: 'stdout', text: `${session.id}:${prompt}` })
674
- return {
675
- child: {
676
- pid: 7011,
677
- exitCode: 0,
678
- signalCode: null,
679
- },
680
- result: completion.promise,
681
- cancel() {},
682
- }
683
- },
684
- }
685
- },
686
- })
687
-
688
- await runManager.startRun({
689
- runId: 'run-flush-failure-1',
690
- taskSlug: 'task-flush-failure',
691
- sessionId: 'session-flush-failure-1',
692
- title: 'Flush Failure 1',
693
- engine: 'codex',
694
- cwd: process.cwd(),
695
- prompt: 'emit once',
696
- })
697
-
698
- await delay(320)
699
-
700
- const diagnosticsWhileRunning = runManager.getDiagnostics()
701
- assert.equal(diagnosticsWhileRunning.metrics.eventFlushFailureCount, 1)
702
- assert.equal(diagnosticsWhileRunning.metrics.lastEventFlushFailureMessage, 'flush failed once')
703
- assert.equal(diagnosticsWhileRunning.activeRuns[0]?.eventFlushFailureCount, 1)
704
-
705
- completion.resolve({
706
- sessionId: 'session-flush-failure-1',
707
- threadId: 'thread-flush-failure-1',
708
- message: 'done',
709
- })
710
- await delay(60)
711
-
712
- const diagnosticsAfterComplete = runManager.getDiagnostics()
713
- assert.equal(diagnosticsAfterComplete.metrics.totalCompleted, 1)
714
- assert.equal(serverClient.postEventAttempts >= 2, true)
715
- })
716
-
717
- test('runManager 会按批次拆分较大的事件上报', async () => {
718
- const completion = createDeferred()
719
- const serverClient = {
720
- batches: [],
721
- statuses: [],
722
- async postEvents(items = []) {
723
- this.batches.push(items)
724
- return { ok: true }
725
- },
726
- async postStatus(payload = {}) {
727
- this.statuses.push(payload)
728
- return { ok: true }
729
- },
730
- }
731
-
732
- const runManager = createRunManager({
733
- serverClient,
734
- resolveRunner() {
735
- return {
736
- streamSessionPrompt(session, prompt, callbacks = {}) {
737
- callbacks.onEvent?.({ type: 'stdout', text: `${session.id}:${prompt}` })
738
- callbacks.onEvent?.({ type: 'stdout', text: 'x'.repeat(350_000) })
739
- callbacks.onEvent?.({ type: 'stdout', text: 'y'.repeat(350_000) })
740
- return {
741
- child: {
742
- pid: 8011,
743
- exitCode: 0,
744
- signalCode: null,
745
- },
746
- result: completion.promise,
747
- cancel() {},
748
- }
749
- },
750
- }
751
- },
752
- })
753
-
754
- await runManager.startRun({
755
- runId: 'run-large-batch-1',
756
- taskSlug: 'task-large-batch',
757
- sessionId: 'session-large-batch-1',
758
- title: 'Large Batch 1',
759
- engine: 'codex',
760
- cwd: process.cwd(),
761
- prompt: 'emit large events',
762
- })
763
-
764
- completion.resolve({
765
- sessionId: 'session-large-batch-1',
766
- threadId: 'thread-large-batch-1',
767
- message: 'done',
768
- })
769
- await delay(80)
770
-
771
- assert.equal(serverClient.statuses.some((item) => item.status === 'completed'), true)
772
- assert.equal(serverClient.batches.length >= 2, true)
773
- assert.equal(serverClient.batches.flat().some((item) => item.payload?.text?.startsWith('x')), true)
774
- assert.equal(serverClient.batches.flat().some((item) => item.payload?.text?.startsWith('y')), true)
775
- })
776
-
777
- test('runManager 分批上报失败后只重试未成功的批次', async () => {
778
- const completion = createDeferred()
779
- const serverClient = {
780
- events: [],
781
- statuses: [],
782
- postEventAttempts: 0,
783
- async postEvents(items = []) {
784
- this.postEventAttempts += 1
785
- if (this.postEventAttempts === 2) {
786
- throw new Error('second batch failed')
787
- }
788
- this.events.push(...items)
789
- return { ok: true }
790
- },
791
- async postStatus(payload = {}) {
792
- this.statuses.push(payload)
793
- return { ok: true }
794
- },
795
- }
796
-
797
- const runManager = createRunManager({
798
- serverClient,
799
- logger: {
800
- error() {},
801
- },
802
- resolveRunner() {
803
- return {
804
- streamSessionPrompt(session, prompt, callbacks = {}) {
805
- callbacks.onEvent?.({ type: 'stdout', text: `${session.id}:${prompt}` })
806
- callbacks.onEvent?.({ type: 'stdout', text: 'x'.repeat(350_000) })
807
- callbacks.onEvent?.({ type: 'stdout', text: 'y'.repeat(350_000) })
808
- callbacks.onEvent?.({ type: 'stdout', text: 'z'.repeat(350_000) })
809
- return {
810
- child: {
811
- pid: 8012,
812
- exitCode: 0,
813
- signalCode: null,
814
- },
815
- result: completion.promise,
816
- cancel() {},
817
- }
818
- },
819
- }
820
- },
821
- })
822
-
823
- await runManager.startRun({
824
- runId: 'run-large-batch-retry-1',
825
- taskSlug: 'task-large-batch-retry',
826
- sessionId: 'session-large-batch-retry-1',
827
- title: 'Large Batch Retry 1',
828
- engine: 'codex',
829
- cwd: process.cwd(),
830
- prompt: 'emit large events and retry',
831
- })
832
-
833
- await delay(420)
834
-
835
- completion.resolve({
836
- sessionId: 'session-large-batch-retry-1',
837
- threadId: 'thread-large-batch-retry-1',
838
- message: 'done',
839
- })
840
- await delay(80)
841
-
842
- const seqList = serverClient.events.map((item) => item.seq)
843
- assert.equal(serverClient.postEventAttempts >= 4, true)
844
- assert.equal(seqList.length, new Set(seqList).size)
845
- assert.equal(serverClient.events.some((item) => item.payload?.text?.startsWith('x')), true)
846
- assert.equal(serverClient.events.some((item) => item.payload?.text?.startsWith('y')), true)
847
- assert.equal(serverClient.events.some((item) => item.payload?.text?.startsWith('z')), true)
848
- })
849
-
850
- test('classifyStopTimeoutPhase 会区分 stop_timeout 的尾部阶段', () => {
851
- assert.equal(classifyStopTimeoutPhase({}), 'runner_timeout_without_stop_request')
852
- assert.equal(
853
- classifyStopTimeoutPhase({
854
- stopRequestedAt: '2026-03-22T00:00:00.000Z',
855
- stopStage: 'cancel_failed',
856
- }),
857
- 'runner_timeout_before_cancel'
858
- )
859
- assert.equal(
860
- classifyStopTimeoutPhase({
861
- stopRequestedAt: '2026-03-22T00:00:00.000Z',
862
- child: {
863
- __promptxStopControl: {
864
- requestedAt: '2026-03-22T00:00:00.000Z',
865
- gracefulSignalAt: '2026-03-22T00:00:01.000Z',
866
- forceKillAttemptedAt: '',
867
- exitObservedAt: '',
868
- exitCode: null,
869
- signalCode: '',
870
- lastKnownAlive: true,
871
- cancelErrorMessage: '',
872
- },
873
- },
874
- }),
875
- 'cli_not_exiting'
876
- )
877
- assert.equal(
878
- classifyStopTimeoutPhase({
879
- stopRequestedAt: '2026-03-22T00:00:00.000Z',
880
- child: {
881
- __promptxStopControl: {
882
- requestedAt: '2026-03-22T00:00:00.000Z',
883
- gracefulSignalAt: '2026-03-22T00:00:01.000Z',
884
- forceKillAttemptedAt: '2026-03-22T00:00:03.000Z',
885
- exitObservedAt: '',
886
- exitCode: null,
887
- signalCode: '',
888
- lastKnownAlive: true,
889
- cancelErrorMessage: '',
890
- },
891
- },
892
- }),
893
- 'os_kill_slow'
894
- )
895
- assert.equal(
896
- classifyStopTimeoutPhase({
897
- stopRequestedAt: '2026-03-22T00:00:00.000Z',
898
- child: {
899
- __promptxStopControl: {
900
- requestedAt: '2026-03-22T00:00:00.000Z',
901
- gracefulSignalAt: '2026-03-22T00:00:01.000Z',
902
- forceKillAttemptedAt: '2026-03-22T00:00:03.000Z',
903
- exitObservedAt: '2026-03-22T00:00:04.000Z',
904
- exitCode: null,
905
- signalCode: '',
906
- lastKnownAlive: false,
907
- cancelErrorMessage: '',
908
- },
909
- },
910
- }),
911
- 'runner_finalize_after_exit'
912
- )
913
- })