@shepai/cli 1.37.0 → 1.37.2

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 (63) hide show
  1. package/dist/packages/core/src/application/use-cases/agents/stop-agent-run.use-case.d.ts +5 -2
  2. package/dist/packages/core/src/application/use-cases/agents/stop-agent-run.use-case.d.ts.map +1 -1
  3. package/dist/packages/core/src/application/use-cases/agents/stop-agent-run.use-case.js +21 -10
  4. package/dist/packages/core/src/application/use-cases/features/resume-feature.use-case.d.ts.map +1 -1
  5. package/dist/packages/core/src/application/use-cases/features/resume-feature.use-case.js +11 -0
  6. package/dist/packages/core/src/infrastructure/services/agents/feature-agent/feature-agent-worker.d.ts.map +1 -1
  7. package/dist/packages/core/src/infrastructure/services/agents/feature-agent/feature-agent-worker.js +11 -3
  8. package/dist/packages/core/src/infrastructure/services/agents/feature-agent/nodes/implement.node.d.ts.map +1 -1
  9. package/dist/packages/core/src/infrastructure/services/agents/feature-agent/nodes/implement.node.js +2 -1
  10. package/dist/packages/core/src/infrastructure/services/agents/feature-agent/nodes/merge.node.d.ts.map +1 -1
  11. package/dist/packages/core/src/infrastructure/services/agents/feature-agent/nodes/merge.node.js +4 -1
  12. package/dist/packages/core/src/infrastructure/services/agents/feature-agent/nodes/node-helpers.d.ts.map +1 -1
  13. package/dist/packages/core/src/infrastructure/services/agents/feature-agent/nodes/node-helpers.js +2 -1
  14. package/dist/packages/core/src/infrastructure/services/agents/feature-agent/phase-timing-context.d.ts +8 -0
  15. package/dist/packages/core/src/infrastructure/services/agents/feature-agent/phase-timing-context.d.ts.map +1 -1
  16. package/dist/packages/core/src/infrastructure/services/agents/feature-agent/phase-timing-context.js +29 -0
  17. package/dist/src/presentation/cli/commands/agent/stop.command.d.ts +1 -1
  18. package/dist/src/presentation/cli/commands/agent/stop.command.js +1 -1
  19. package/dist/src/presentation/cli/commands/feat/show.command.d.ts.map +1 -1
  20. package/dist/src/presentation/cli/commands/feat/show.command.js +177 -54
  21. package/dist/tsconfig.build.tsbuildinfo +1 -1
  22. package/package.json +1 -1
  23. package/web/.next/BUILD_ID +1 -1
  24. package/web/.next/build-manifest.json +2 -2
  25. package/web/.next/cache/.previewinfo +1 -1
  26. package/web/.next/cache/.rscinfo +1 -1
  27. package/web/.next/cache/config.json +3 -3
  28. package/web/.next/fallback-build-manifest.json +2 -2
  29. package/web/.next/prerender-manifest.json +3 -3
  30. package/web/.next/required-server-files.js +1 -1
  31. package/web/.next/required-server-files.json +1 -1
  32. package/web/.next/server/app/_global-error.html +2 -2
  33. package/web/.next/server/app/_global-error.rsc +1 -1
  34. package/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  35. package/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  36. package/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  37. package/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  38. package/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  39. package/web/.next/server/chunks/ssr/[root-of-the-server]__f12b60be._.js +1 -1
  40. package/web/.next/server/pages/500.html +2 -2
  41. package/web/.next/server/server-reference-manifest.js +1 -1
  42. package/web/.next/server/server-reference-manifest.json +1 -1
  43. package/web/.next/standalone/src/presentation/web/.next/BUILD_ID +1 -1
  44. package/web/.next/standalone/src/presentation/web/.next/build-manifest.json +2 -2
  45. package/web/.next/standalone/src/presentation/web/.next/prerender-manifest.json +3 -3
  46. package/web/.next/standalone/src/presentation/web/.next/required-server-files.json +1 -1
  47. package/web/.next/standalone/src/presentation/web/.next/server/app/_global-error.html +2 -2
  48. package/web/.next/standalone/src/presentation/web/.next/server/app/_global-error.rsc +1 -1
  49. package/web/.next/standalone/src/presentation/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  50. package/web/.next/standalone/src/presentation/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  51. package/web/.next/standalone/src/presentation/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  52. package/web/.next/standalone/src/presentation/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  53. package/web/.next/standalone/src/presentation/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  54. package/web/.next/standalone/src/presentation/web/.next/server/chunks/ssr/[root-of-the-server]__f12b60be._.js +1 -1
  55. package/web/.next/standalone/src/presentation/web/.next/server/pages/500.html +2 -2
  56. package/web/.next/standalone/src/presentation/web/.next/server/server-reference-manifest.js +1 -1
  57. package/web/.next/standalone/src/presentation/web/.next/server/server-reference-manifest.json +1 -1
  58. package/web/.next/standalone/src/presentation/web/server.js +1 -1
  59. package/web/.next/trace +1 -1
  60. package/web/.next/trace-build +1 -1
  61. /package/web/.next/static/{zoaB52IxAh-lSl8Qreb0t → AaXDy79mMK8ywoCbOquJB}/_buildManifest.js +0 -0
  62. /package/web/.next/static/{zoaB52IxAh-lSl8Qreb0t → AaXDy79mMK8ywoCbOquJB}/_clientMiddlewareManifest.json +0 -0
  63. /package/web/.next/static/{zoaB52IxAh-lSl8Qreb0t → AaXDy79mMK8ywoCbOquJB}/_ssgManifest.js +0 -0
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * Stop Agent Run Use Case
3
3
  *
4
- * Sends SIGTERM to a running agent's worker process and marks it as cancelled.
4
+ * Sends SIGTERM to a running agent's worker process and marks it as interrupted.
5
+ * Using "interrupted" (not "cancelled") so the run is resumable via `shep feat resume`.
5
6
  */
6
7
  import type { IAgentRunRepository } from '../../ports/output/agents/agent-run-repository.interface.js';
8
+ import type { IPhaseTimingRepository } from '../../ports/output/agents/phase-timing-repository.interface.js';
7
9
  export declare class StopAgentRunUseCase {
8
10
  private readonly agentRunRepository;
9
- constructor(agentRunRepository: IAgentRunRepository);
11
+ private readonly phaseTimingRepository;
12
+ constructor(agentRunRepository: IAgentRunRepository, phaseTimingRepository: IPhaseTimingRepository);
10
13
  execute(id: string): Promise<{
11
14
  stopped: boolean;
12
15
  reason: string;
@@ -1 +1 @@
1
- {"version":3,"file":"stop-agent-run.use-case.d.ts","sourceRoot":"","sources":["../../../../../../../packages/core/src/application/use-cases/agents/stop-agent-run.use-case.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,6DAA6D,CAAC;AAGvG,qBACa,mBAAmB;IAG5B,OAAO,CAAC,QAAQ,CAAC,kBAAkB;gBAAlB,kBAAkB,EAAE,mBAAmB;IAGpD,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CA4DzE"}
1
+ {"version":3,"file":"stop-agent-run.use-case.d.ts","sourceRoot":"","sources":["../../../../../../../packages/core/src/application/use-cases/agents/stop-agent-run.use-case.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,6DAA6D,CAAC;AACvG,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,gEAAgE,CAAC;AAI7G,qBACa,mBAAmB;IAG5B,OAAO,CAAC,QAAQ,CAAC,kBAAkB;IAEnC,OAAO,CAAC,QAAQ,CAAC,qBAAqB;gBAFrB,kBAAkB,EAAE,mBAAmB,EAEvC,qBAAqB,EAAE,sBAAsB;IAG1D,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CAmEzE"}
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Stop Agent Run Use Case
3
3
  *
4
- * Sends SIGTERM to a running agent's worker process and marks it as cancelled.
4
+ * Sends SIGTERM to a running agent's worker process and marks it as interrupted.
5
+ * Using "interrupted" (not "cancelled") so the run is resumable via `shep feat resume`.
5
6
  */
6
7
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
7
8
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -17,10 +18,13 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
17
18
  };
18
19
  import { injectable, inject } from 'tsyringe';
19
20
  import { AgentRunStatus } from '../../../domain/generated/output.js';
21
+ import { recordLifecycleEvent } from '../../../infrastructure/services/agents/feature-agent/phase-timing-context.js';
20
22
  let StopAgentRunUseCase = class StopAgentRunUseCase {
21
23
  agentRunRepository;
22
- constructor(agentRunRepository) {
24
+ phaseTimingRepository;
25
+ constructor(agentRunRepository, phaseTimingRepository) {
23
26
  this.agentRunRepository = agentRunRepository;
27
+ this.phaseTimingRepository = phaseTimingRepository;
24
28
  }
25
29
  async execute(id) {
26
30
  const run = await this.agentRunRepository.findById(id);
@@ -37,14 +41,15 @@ let StopAgentRunUseCase = class StopAgentRunUseCase {
37
41
  return { stopped: false, reason: `Agent run already in terminal state: ${run.status}` };
38
42
  }
39
43
  if (!run.pid) {
40
- // No PID — just mark as cancelled
44
+ // No PID — just mark as interrupted (resumable)
41
45
  const now = new Date();
42
- await this.agentRunRepository.updateStatus(id, AgentRunStatus.cancelled, {
43
- error: 'Cancelled by user (no PID)',
46
+ await this.agentRunRepository.updateStatus(id, AgentRunStatus.interrupted, {
47
+ error: 'Stopped by user (no PID)',
44
48
  completedAt: now,
45
49
  updatedAt: now,
46
50
  });
47
- return { stopped: true, reason: 'Marked as cancelled (no PID to signal)' };
51
+ await recordLifecycleEvent('run:stopped', id, this.phaseTimingRepository);
52
+ return { stopped: true, reason: 'Marked as interrupted (no PID to signal)' };
48
53
  }
49
54
  // Check if process is alive
50
55
  let alive = false;
@@ -65,22 +70,28 @@ let StopAgentRunUseCase = class StopAgentRunUseCase {
65
70
  }
66
71
  }
67
72
  const now = new Date();
68
- await this.agentRunRepository.updateStatus(id, AgentRunStatus.cancelled, {
69
- error: 'Cancelled by user',
73
+ await this.agentRunRepository.updateStatus(id, AgentRunStatus.interrupted, {
74
+ error: 'Stopped by user',
70
75
  completedAt: now,
71
76
  updatedAt: now,
72
77
  });
78
+ // For alive processes, the worker's SIGTERM handler records run:stopped.
79
+ // For dead processes, record it here since the worker can't.
80
+ if (!alive) {
81
+ await recordLifecycleEvent('run:stopped', id, this.phaseTimingRepository);
82
+ }
73
83
  return {
74
84
  stopped: true,
75
85
  reason: alive
76
86
  ? `Sent SIGTERM to PID ${run.pid}`
77
- : `Process already dead, marked as cancelled`,
87
+ : `Process already dead, marked as interrupted`,
78
88
  };
79
89
  }
80
90
  };
81
91
  StopAgentRunUseCase = __decorate([
82
92
  injectable(),
83
93
  __param(0, inject('IAgentRunRepository')),
84
- __metadata("design:paramtypes", [Object])
94
+ __param(1, inject('IPhaseTimingRepository')),
95
+ __metadata("design:paramtypes", [Object, Object])
85
96
  ], StopAgentRunUseCase);
86
97
  export { StopAgentRunUseCase };
@@ -1 +1 @@
1
- {"version":3,"file":"resume-feature.use-case.d.ts","sourceRoot":"","sources":["../../../../../../../packages/core/src/application/use-cases/features/resume-feature.use-case.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,qCAAqC,CAAC;AAE7E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iEAAiE,CAAC;AAC1G,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,6DAA6D,CAAC;AACvG,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,8DAA8D,CAAC;AAChH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2DAA2D,CAAC;AAQlG,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,QAAQ,CAAC;CAClB;AAED,qBACa,oBAAoB;IAG7B,OAAO,CAAC,QAAQ,CAAC,WAAW;IAE5B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAExB,OAAO,CAAC,QAAQ,CAAC,cAAc;IAE/B,OAAO,CAAC,QAAQ,CAAC,eAAe;gBANf,WAAW,EAAE,kBAAkB,EAE/B,OAAO,EAAE,mBAAmB,EAE5B,cAAc,EAAE,2BAA2B,EAE3C,eAAe,EAAE,gBAAgB;IAG9C,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC;CA+E/D"}
1
+ {"version":3,"file":"resume-feature.use-case.d.ts","sourceRoot":"","sources":["../../../../../../../packages/core/src/application/use-cases/features/resume-feature.use-case.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,qCAAqC,CAAC;AAE7E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iEAAiE,CAAC;AAC1G,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,6DAA6D,CAAC;AACvG,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,8DAA8D,CAAC;AAChH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2DAA2D,CAAC;AAQlG,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,QAAQ,CAAC;CAClB;AAED,qBACa,oBAAoB;IAG7B,OAAO,CAAC,QAAQ,CAAC,WAAW;IAE5B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAExB,OAAO,CAAC,QAAQ,CAAC,cAAc;IAE/B,OAAO,CAAC,QAAQ,CAAC,eAAe;gBANf,WAAW,EAAE,kBAAkB,EAE/B,OAAO,EAAE,mBAAmB,EAE5B,cAAc,EAAE,2BAA2B,EAE3C,eAAe,EAAE,gBAAgB;IAG9C,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC;CA6F/D"}
@@ -50,6 +50,17 @@ let ResumeFeatureUseCase = class ResumeFeatureUseCase {
50
50
  if (!lastRun) {
51
51
  throw new Error(`No agent run found for feature "${feature.name}"`);
52
52
  }
53
+ // Detect crashed processes: if DB says "running" but the process is dead,
54
+ // mark it as interrupted so it becomes resumable
55
+ if ((lastRun.status === AgentRunStatus.running || lastRun.status === AgentRunStatus.pending) &&
56
+ feature.agentRunId) {
57
+ await this.processService.checkAndMarkCrashed(feature.agentRunId);
58
+ // Re-read after potential status change
59
+ const refreshed = await this.runRepo.findById(feature.agentRunId);
60
+ if (refreshed) {
61
+ Object.assign(lastRun, refreshed);
62
+ }
63
+ }
53
64
  // Validate the run is in a resumable state
54
65
  if (lastRun.status === AgentRunStatus.running) {
55
66
  throw new Error('Agent is still running — stop it first before resuming');
@@ -1 +1 @@
1
- {"version":3,"file":"feature-agent-worker.d.ts","sourceRoot":"","sources":["../../../../../../../../packages/core/src/infrastructure/services/agents/feature-agent/feature-agent-worker.ts"],"names":[],"mappings":";AACA;;;;;;;;GAQG;AAEH,OAAO,kBAAkB,CAAC;AAyB1B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAElE,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CA6C1D;AAgCD;;;GAGG;AACH,wBAAsB,SAAS,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CA6J/D"}
1
+ {"version":3,"file":"feature-agent-worker.d.ts","sourceRoot":"","sources":["../../../../../../../../packages/core/src/infrastructure/services/agents/feature-agent/feature-agent-worker.ts"],"names":[],"mappings":";AACA;;;;;;;;GAQG;AAEH,OAAO,kBAAkB,CAAC;AAqB1B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAElE,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CA6C1D;AAgCD;;;GAGG;AACH,wBAAsB,SAAS,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAoK/D"}
@@ -19,7 +19,7 @@ import { AgentRunStatus } from '../../../../domain/generated/output.js';
19
19
  import { initializeSettings } from '../../../services/settings.service.js';
20
20
  import { InitializeSettingsUseCase } from '../../../../application/use-cases/settings/initialize-settings.use-case.js';
21
21
  import { setHeartbeatContext } from './heartbeat.js';
22
- import { setPhaseTimingContext, recordApprovalWaitStart, getLastTimingId, } from './phase-timing-context.js';
22
+ import { setPhaseTimingContext, recordLifecycleEvent } from './phase-timing-context.js';
23
23
  import { setLifecycleContext } from './lifecycle-context.js';
24
24
  import { generatePrYaml } from './nodes/pr-yaml-generator.js';
25
25
  /**
@@ -151,8 +151,13 @@ export async function runWorker(args) {
151
151
  // Set phase timing context so executeNode() records per-phase durations
152
152
  const timingRepository = container.resolve('IPhaseTimingRepository');
153
153
  setPhaseTimingContext(args.runId, timingRepository);
154
+ // Capture repos for SIGTERM handler
155
+ runRepoForSignal = runRepository;
156
+ timingRepoForSignal = timingRepository;
154
157
  // Set lifecycle context so nodes update the feature lifecycle as they execute
155
158
  setLifecycleContext(args.featureId, featureRepository);
159
+ // Record lifecycle event
160
+ await recordLifecycleEvent(args.resume ? 'run:resumed' : 'run:started');
156
161
  try {
157
162
  const graphConfig = { configurable: { thread_id: checkpointId } };
158
163
  let result;
@@ -193,8 +198,6 @@ export async function runWorker(args) {
193
198
  // Check if graph was interrupted (human-in-the-loop approval needed)
194
199
  const interruptPayload = result.__interrupt__;
195
200
  if (interruptPayload && interruptPayload.length > 0) {
196
- // Record approval wait start on the last phase timing
197
- await recordApprovalWaitStart(getLastTimingId());
198
201
  const now = new Date();
199
202
  await runRepository.updateStatus(args.runId, AgentRunStatus.waitingApproval, {
200
203
  updatedAt: now,
@@ -210,6 +213,7 @@ export async function runWorker(args) {
210
213
  completedAt: failedAt,
211
214
  updatedAt: failedAt,
212
215
  });
216
+ await recordLifecycleEvent('run:failed');
213
217
  log(`Run marked as failed: ${result.error}`);
214
218
  return;
215
219
  }
@@ -219,6 +223,7 @@ export async function runWorker(args) {
219
223
  completedAt,
220
224
  updatedAt: completedAt,
221
225
  });
226
+ await recordLifecycleEvent('run:completed');
222
227
  log('Run marked as completed');
223
228
  }
224
229
  catch (error) {
@@ -231,6 +236,7 @@ export async function runWorker(args) {
231
236
  completedAt: failedAt,
232
237
  updatedAt: failedAt,
233
238
  });
239
+ await recordLifecycleEvent('run:failed');
234
240
  log('Run marked as failed');
235
241
  }
236
242
  }
@@ -251,6 +257,7 @@ process.on('disconnect', () => {
251
257
  // Handle SIGTERM for graceful shutdown
252
258
  let runIdForSignal;
253
259
  let runRepoForSignal;
260
+ let timingRepoForSignal;
254
261
  process.on('SIGTERM', async () => {
255
262
  log('Received SIGTERM, shutting down...');
256
263
  if (runIdForSignal && runRepoForSignal) {
@@ -260,6 +267,7 @@ process.on('SIGTERM', async () => {
260
267
  completedAt: now,
261
268
  updatedAt: now,
262
269
  });
270
+ await recordLifecycleEvent('run:stopped', runIdForSignal, timingRepoForSignal);
263
271
  }
264
272
  process.exit(0);
265
273
  });
@@ -1 +1 @@
1
- {"version":3,"file":"implement.node.d.ts","sourceRoot":"","sources":["../../../../../../../../../packages/core/src/infrastructure/services/agents/feature-agent/nodes/implement.node.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,+DAA+D,CAAC;AACpG,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAiErD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,cAAc,IAG5C,OAAO,iBAAiB,KAAG,OAAO,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC,CA+K7E"}
1
+ {"version":3,"file":"implement.node.d.ts","sourceRoot":"","sources":["../../../../../../../../../packages/core/src/infrastructure/services/agents/feature-agent/nodes/implement.node.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,+DAA+D,CAAC;AACpG,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAqErD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,cAAc,IAG5C,OAAO,iBAAiB,KAAG,OAAO,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC,CAgL7E"}
@@ -12,7 +12,7 @@ import { join } from 'node:path';
12
12
  import { interrupt, isGraphBubbleUp } from '@langchain/langgraph';
13
13
  import { createNodeLogger, readSpecFile, buildExecutorOptions, shouldInterrupt, safeYamlLoad, retryExecute, getCompletedPhases, markPhaseComplete, } from './node-helpers.js';
14
14
  import { reportNodeStart } from '../heartbeat.js';
15
- import { recordPhaseStart, recordPhaseEnd } from '../phase-timing-context.js';
15
+ import { recordPhaseStart, recordPhaseEnd, recordApprovalWaitStart, } from '../phase-timing-context.js';
16
16
  import { updateNodeLifecycle } from '../lifecycle-context.js';
17
17
  import { buildImplementPhasePrompt, } from './prompts/implement.prompt.js';
18
18
  /**
@@ -145,6 +145,7 @@ export function createImplementNode(executor) {
145
145
  await recordPhaseEnd(implementTimingId, Date.now() - startTime);
146
146
  if (shouldInterrupt('implement', state.approvalGates)) {
147
147
  log.info('Interrupting for human approval');
148
+ await recordApprovalWaitStart(implementTimingId);
148
149
  interrupt({
149
150
  node: 'implement',
150
151
  message: `Implementation complete: ${totalTasks} tasks across ${totalPhases} phases. Approve to finish.`,
@@ -1 +1 @@
1
- {"version":3,"file":"merge.node.d.ts","sourceRoot":"","sources":["../../../../../../../../../packages/core/src/infrastructure/services/agents/feature-agent/nodes/merge.node.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iEAAiE,CAAC;AACrG,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yEAAyE,CAAC;AAOlH,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,aAAa,CAAC;IAC5B,cAAc,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IAChF,iBAAiB,EAAE,IAAI,CAAC,kBAAkB,EAAE,UAAU,GAAG,QAAQ,CAAC,CAAC;CACpE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,aAAa,IAGnC,OAAO,iBAAiB,KAAG,OAAO,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC,CAqJ7E"}
1
+ {"version":3,"file":"merge.node.d.ts","sourceRoot":"","sources":["../../../../../../../../../packages/core/src/infrastructure/services/agents/feature-agent/nodes/merge.node.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iEAAiE,CAAC;AACrG,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yEAAyE,CAAC;AAWlH,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,aAAa,CAAC;IAC5B,cAAc,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IAChF,iBAAiB,EAAE,IAAI,CAAC,kBAAkB,EAAE,UAAU,GAAG,QAAQ,CAAC,CAAC;CACpE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,aAAa,IAGnC,OAAO,iBAAiB,KAAG,OAAO,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC,CAwJ7E"}
@@ -13,7 +13,7 @@ import { interrupt, isGraphBubbleUp } from '@langchain/langgraph';
13
13
  import { SdlcLifecycle, PrStatus } from '../../../../../domain/generated/output.js';
14
14
  import { createNodeLogger, shouldInterrupt } from './node-helpers.js';
15
15
  import { reportNodeStart } from '../heartbeat.js';
16
- import { recordPhaseStart, recordPhaseEnd } from '../phase-timing-context.js';
16
+ import { recordPhaseStart, recordPhaseEnd, recordApprovalWaitStart, } from '../phase-timing-context.js';
17
17
  import { updateNodeLifecycle } from '../lifecycle-context.js';
18
18
  /**
19
19
  * Factory that creates the merge node function.
@@ -82,6 +82,9 @@ export function createMergeNode(deps) {
82
82
  // --- Step 4: Merge approval gate ---
83
83
  if (shouldInterrupt('merge', state.approvalGates)) {
84
84
  log.info('Interrupting for merge approval');
85
+ // Close timing before interrupt so the pre-approval work has a duration
86
+ await recordPhaseEnd(mergeTimingId, Date.now() - startTime);
87
+ await recordApprovalWaitStart(mergeTimingId);
85
88
  const diffSummary = await gitPrService.getPrDiffSummary(cwd, baseBranch);
86
89
  interrupt({
87
90
  node: 'merge',
@@ -1 +1 @@
1
- {"version":3,"file":"node-helpers.d.ts","sourceRoot":"","sources":["../../../../../../../../../packages/core/src/infrastructure/services/agents/feature-agent/nodes/node-helpers.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,OAAO,KAAK,EACV,cAAc,EACd,qBAAqB,EACrB,oBAAoB,EACrB,MAAM,+DAA+D,CAAC;AACvE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAClE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAKrD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM;kBAE/B,MAAM,GAAG,IAAI;mBAIZ,MAAM,GAAG,IAAI;EAK/B;AAED,MAAM,MAAM,UAAU,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE7D;;GAEG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAMtE;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,iBAAiB,GAAG,qBAAqB,CAMpF;AAeD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAQrD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,SAAS,GAAG,OAAO,CAS3F;AAMD,MAAM,MAAM,aAAa,GAAG,eAAe,GAAG,mBAAmB,GAAG,eAAe,GAAG,SAAS,CAAC;AAMhG;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,YAAY,EAAE,MAAM,GAAG,aAAa,CAKjE;AAED,MAAM,WAAW,YAAY;IAC3B,wDAAwD;IACxD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kFAAkF;IAClF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0CAA0C;IAC1C,MAAM,CAAC,EAAE,UAAU,CAAC;CACrB;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,CAChC,QAAQ,EAAE,cAAc,EACxB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,qBAAqB,EAC9B,SAAS,CAAC,EAAE,YAAY,GACvB,OAAO,CAAC,oBAAoB,CAAC,CA6B/B;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAW5D;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,UAAU,GAAG,IAAI,CAwB1F;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,cAAc,EACxB,WAAW,EAAE,CAAC,KAAK,EAAE,iBAAiB,EAAE,GAAG,EAAE,UAAU,KAAK,MAAM,GACjE,CAAC,KAAK,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC,CAgFnE"}
1
+ {"version":3,"file":"node-helpers.d.ts","sourceRoot":"","sources":["../../../../../../../../../packages/core/src/infrastructure/services/agents/feature-agent/nodes/node-helpers.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,OAAO,KAAK,EACV,cAAc,EACd,qBAAqB,EACrB,oBAAoB,EACrB,MAAM,+DAA+D,CAAC;AACvE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAClE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AASrD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM;kBAE/B,MAAM,GAAG,IAAI;mBAIZ,MAAM,GAAG,IAAI;EAK/B;AAED,MAAM,MAAM,UAAU,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE7D;;GAEG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAMtE;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,iBAAiB,GAAG,qBAAqB,CAMpF;AAeD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAQrD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,SAAS,GAAG,OAAO,CAS3F;AAMD,MAAM,MAAM,aAAa,GAAG,eAAe,GAAG,mBAAmB,GAAG,eAAe,GAAG,SAAS,CAAC;AAMhG;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,YAAY,EAAE,MAAM,GAAG,aAAa,CAKjE;AAED,MAAM,WAAW,YAAY;IAC3B,wDAAwD;IACxD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kFAAkF;IAClF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0CAA0C;IAC1C,MAAM,CAAC,EAAE,UAAU,CAAC;CACrB;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,CAChC,QAAQ,EAAE,cAAc,EACxB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,qBAAqB,EAC9B,SAAS,CAAC,EAAE,YAAY,GACvB,OAAO,CAAC,oBAAoB,CAAC,CA6B/B;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAW5D;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,UAAU,GAAG,IAAI,CAwB1F;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,cAAc,EACxB,WAAW,EAAE,CAAC,KAAK,EAAE,iBAAiB,EAAE,GAAG,EAAE,UAAU,KAAK,MAAM,GACjE,CAAC,KAAK,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC,CAiFnE"}
@@ -9,7 +9,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
  import { interrupt, isGraphBubbleUp } from '@langchain/langgraph';
11
11
  import { reportNodeStart } from '../heartbeat.js';
12
- import { recordPhaseStart, recordPhaseEnd } from '../phase-timing-context.js';
12
+ import { recordPhaseStart, recordPhaseEnd, recordApprovalWaitStart, } from '../phase-timing-context.js';
13
13
  import { updateNodeLifecycle } from '../lifecycle-context.js';
14
14
  /**
15
15
  * Create a scoped logger that prefixes messages with the node name.
@@ -245,6 +245,7 @@ export function executeNode(nodeName, executor, buildPrompt) {
245
245
  // Human-in-the-loop: interrupt after node execution for review
246
246
  if (shouldInterrupt(nodeName, state.approvalGates)) {
247
247
  log.info('Interrupting for human approval');
248
+ await recordApprovalWaitStart(timingId);
248
249
  interrupt({
249
250
  node: nodeName,
250
251
  result: result.result.slice(0, 500),
@@ -31,6 +31,14 @@ export declare function recordPhaseStart(phase: string): Promise<string | null>;
31
31
  * No-op if timingId is null (phase start was skipped or failed).
32
32
  */
33
33
  export declare function recordPhaseEnd(timingId: string | null, durationMs: number): Promise<void>;
34
+ /**
35
+ * Record an instant lifecycle event (e.g., run:started, run:stopped, run:crashed).
36
+ * These are phase timing records with zero duration, used to mark events in the timeline.
37
+ *
38
+ * Can be called with explicit runId and repository (for use outside the worker context,
39
+ * e.g., from use cases like StopAgentRunUseCase or ResumeFeatureUseCase).
40
+ */
41
+ export declare function recordLifecycleEvent(phase: string, explicitRunId?: string, explicitRepo?: IPhaseTimingRepository): Promise<void>;
34
42
  /**
35
43
  * Record the start of an approval wait. Sets waitingApprovalAt on the timing record.
36
44
  * No-op if timingId is null or context is not set.
@@ -1 +1 @@
1
- {"version":3,"file":"phase-timing-context.d.ts","sourceRoot":"","sources":["../../../../../../../../packages/core/src/infrastructure/services/agents/feature-agent/phase-timing-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,wEAAwE,CAAC;AAMrH;;GAEG;AACH,wBAAgB,eAAe,IAAI,MAAM,GAAG,IAAI,CAE/C;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,sBAAsB,GAAG,IAAI,CAG7F;AAED;;GAEG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAI9C;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAqB5E;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/F;AAED;;;GAGG;AACH,wBAAsB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAUpF"}
1
+ {"version":3,"file":"phase-timing-context.d.ts","sourceRoot":"","sources":["../../../../../../../../packages/core/src/infrastructure/services/agents/feature-agent/phase-timing-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,wEAAwE,CAAC;AAMrH;;GAEG;AACH,wBAAgB,eAAe,IAAI,MAAM,GAAG,IAAI,CAE/C;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,sBAAsB,GAAG,IAAI,CAG7F;AAED;;GAEG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAI9C;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAqB5E;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAW/F;AAED;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,MAAM,EACb,aAAa,CAAC,EAAE,MAAM,EACtB,YAAY,CAAC,EAAE,sBAAsB,GACpC,OAAO,CAAC,IAAI,CAAC,CAoBf;AAED;;;GAGG;AACH,wBAAsB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAUpF"}
@@ -76,6 +76,35 @@ export async function recordPhaseEnd(timingId, durationMs) {
76
76
  // Swallow — timing update failure is non-fatal
77
77
  }
78
78
  }
79
+ /**
80
+ * Record an instant lifecycle event (e.g., run:started, run:stopped, run:crashed).
81
+ * These are phase timing records with zero duration, used to mark events in the timeline.
82
+ *
83
+ * Can be called with explicit runId and repository (for use outside the worker context,
84
+ * e.g., from use cases like StopAgentRunUseCase or ResumeFeatureUseCase).
85
+ */
86
+ export async function recordLifecycleEvent(phase, explicitRunId, explicitRepo) {
87
+ const runId = explicitRunId ?? contextRunId;
88
+ const repo = explicitRepo ?? contextRepository;
89
+ if (!runId || !repo)
90
+ return;
91
+ const now = new Date();
92
+ try {
93
+ await repo.save({
94
+ id: randomUUID(),
95
+ agentRunId: runId,
96
+ phase,
97
+ startedAt: now,
98
+ completedAt: now,
99
+ durationMs: BigInt(0),
100
+ createdAt: now,
101
+ updatedAt: now,
102
+ });
103
+ }
104
+ catch {
105
+ // Swallow — lifecycle event recording is non-fatal
106
+ }
107
+ }
79
108
  /**
80
109
  * Record the start of an approval wait. Sets waitingApprovalAt on the timing record.
81
110
  * No-op if timingId is null or context is not set.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Agent Stop Command
3
3
  *
4
- * Sends SIGTERM to a running agent and marks it as cancelled.
4
+ * Sends SIGTERM to a running agent and marks it as interrupted (resumable).
5
5
  *
6
6
  * Usage:
7
7
  * shep agent stop <id>
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Agent Stop Command
3
3
  *
4
- * Sends SIGTERM to a running agent and marks it as cancelled.
4
+ * Sends SIGTERM to a running agent and marks it as interrupted (resumable).
5
5
  *
6
6
  * Usage:
7
7
  * shep agent stop <id>
@@ -1 +1 @@
1
- {"version":3,"file":"show.command.d.ts","sourceRoot":"","sources":["../../../../../../src/presentation/cli/commands/feat/show.command.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAmFpC,wBAAgB,iBAAiB,IAAI,OAAO,CAiO3C"}
1
+ {"version":3,"file":"show.command.d.ts","sourceRoot":"","sources":["../../../../../../src/presentation/cli/commands/feat/show.command.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAsTpC,wBAAgB,iBAAiB,IAAI,OAAO,CAwJ3C"}
@@ -29,6 +29,19 @@ const NODE_TO_APPROVE = {
29
29
  implement: 'Approve Implementation',
30
30
  merge: 'Approve Merge',
31
31
  };
32
+ /** Map lifecycle event phases to display labels and symbols. */
33
+ const LIFECYCLE_EVENTS = {
34
+ 'run:started': { label: 'started', color: colors.info },
35
+ 'run:resumed': { label: 'resumed', color: colors.info },
36
+ 'run:completed': { label: 'completed', color: colors.success },
37
+ 'run:failed': { label: 'failed', color: colors.error },
38
+ 'run:stopped': { label: 'stopped', color: colors.warning },
39
+ 'run:crashed': { label: 'crashed', color: colors.error },
40
+ };
41
+ /** Check if a phase is a lifecycle event (run:*). */
42
+ function isLifecycleEvent(phase) {
43
+ return phase.startsWith('run:');
44
+ }
32
45
  function formatStatus(feature, run) {
33
46
  if (!run) {
34
47
  return `${colors.muted(symbols.dotEmpty)} ${colors.muted(feature.lifecycle)}`;
@@ -81,6 +94,167 @@ function isProcessAlive(pid) {
81
94
  return false;
82
95
  }
83
96
  }
97
+ /**
98
+ * Group timings by agentRunId, preserving order.
99
+ */
100
+ function groupTimingsByRun(timings) {
101
+ const groups = [];
102
+ let current = null;
103
+ for (const t of timings) {
104
+ // eslint-disable-next-line @typescript-eslint/prefer-optional-chain
105
+ if (!current || current.runId !== t.agentRunId) {
106
+ current = { runId: t.agentRunId, timings: [] };
107
+ groups.push(current);
108
+ }
109
+ current.timings.push(t);
110
+ }
111
+ return groups;
112
+ }
113
+ /**
114
+ * Render a single timing entry (node phase, not lifecycle event).
115
+ */
116
+ function renderNodeTiming(t, isLast, isWaiting, isRunTerminal, run, maxDurationMs, maxBar) {
117
+ const lines = [];
118
+ const isSubPhase = t.phase.includes(':');
119
+ const label = isSubPhase
120
+ ? ` \u21b3 ${t.phase.split(':')[1]}`.padEnd(16)
121
+ : (NODE_TO_PHASE[t.phase] ?? t.phase).padEnd(16);
122
+ // Completed phase - green bar with duration
123
+ if (t.completedAt && t.durationMs != null) {
124
+ const ms = Number(t.durationMs);
125
+ const secs = (ms / 1000).toFixed(1);
126
+ const barLen = maxDurationMs > 0
127
+ ? Math.min(maxBar, Math.max(1, Math.round((ms / maxDurationMs) * maxBar)))
128
+ : 1;
129
+ const bar = `${colors.success('\u2588'.repeat(barLen))}${colors.muted('\u2591'.repeat(maxBar - barLen))}`;
130
+ lines.push(`${label} ${bar} ${secs}s`);
131
+ }
132
+ // Waiting for approval - only the LAST timing
133
+ else if (isLast && isWaiting) {
134
+ lines.push(`${label} ${colors.warning('awaiting review')}`);
135
+ }
136
+ // Crashed/stopped phase - red bar with frozen duration
137
+ else if (isRunTerminal) {
138
+ const endTime = run?.updatedAt
139
+ ? new Date(run.updatedAt).getTime()
140
+ : Date.now();
141
+ const elapsedMs = Math.max(0, endTime - new Date(t.startedAt).getTime());
142
+ const secs = (elapsedMs / 1000).toFixed(1);
143
+ const barLen = maxDurationMs > 0
144
+ ? Math.min(maxBar, Math.max(1, Math.round((elapsedMs / maxDurationMs) * maxBar)))
145
+ : 1;
146
+ const bar = `${colors.error('\u2588'.repeat(barLen))}${colors.muted('\u2591'.repeat(maxBar - barLen))}`;
147
+ lines.push(`${label} ${bar} ${secs}s (crashed)`);
148
+ }
149
+ // Running phase - blue bar with elapsed time
150
+ else {
151
+ const elapsedMs = Math.max(0, Date.now() - new Date(t.startedAt).getTime());
152
+ const secs = (elapsedMs / 1000).toFixed(1);
153
+ const barLen = maxDurationMs > 0
154
+ ? Math.min(maxBar, Math.max(1, Math.round((elapsedMs / maxDurationMs) * maxBar)))
155
+ : 1;
156
+ const bar = `${colors.info('\u2588'.repeat(barLen))}${colors.muted('\u2591'.repeat(maxBar - barLen))}`;
157
+ lines.push(`${label} ${bar} ${secs}s (running)`);
158
+ }
159
+ // Show approval wait time under gated phases
160
+ if (t.approvalWaitMs != null && Number(t.approvalWaitMs) > 0) {
161
+ const waitMs = Number(t.approvalWaitMs);
162
+ const waitSecs = (waitMs / 1000).toFixed(1);
163
+ const waitLabel = ' \u21b3 approval'.padEnd(16);
164
+ const waitBarLen = maxDurationMs > 0
165
+ ? Math.min(maxBar, Math.max(1, Math.round((waitMs / maxDurationMs) * maxBar)))
166
+ : 1;
167
+ const waitBar = `${colors.warning('\u2588'.repeat(waitBarLen))}${colors.muted('\u2591'.repeat(maxBar - waitBarLen))}`;
168
+ lines.push(`${waitLabel} ${waitBar} ${waitSecs}s`);
169
+ }
170
+ else if (isLast && isWaiting && t.waitingApprovalAt) {
171
+ const waitStart = t.waitingApprovalAt instanceof Date
172
+ ? t.waitingApprovalAt.getTime()
173
+ : Number(t.waitingApprovalAt);
174
+ const waitElapsedMs = Math.max(0, Date.now() - waitStart);
175
+ const waitSecs = (waitElapsedMs / 1000).toFixed(1);
176
+ const waitLabel = ' \u21b3 approval'.padEnd(16);
177
+ const waitBarLen = maxDurationMs > 0
178
+ ? Math.min(maxBar, Math.max(1, Math.round((waitElapsedMs / maxDurationMs) * maxBar)))
179
+ : 1;
180
+ const waitBar = `${colors.warning('\u2588'.repeat(waitBarLen))}${colors.muted('\u2591'.repeat(maxBar - waitBarLen))}`;
181
+ lines.push(`${waitLabel} ${waitBar} ${waitSecs}s (waiting)`);
182
+ }
183
+ return lines;
184
+ }
185
+ /**
186
+ * Render the full phase timing section, grouped by agent run with lifecycle events.
187
+ */
188
+ function renderPhaseTimings(timings, currentRun) {
189
+ const groups = groupTimingsByRun(timings);
190
+ const multiRun = groups.length > 1;
191
+ // Compute max duration across ALL timings for consistent bar scaling
192
+ const nodeTimings = timings.filter((t) => !isLifecycleEvent(t.phase));
193
+ const maxDurationMs = Math.max(...nodeTimings.map((t) => (t.durationMs != null ? Number(t.durationMs) : 0)), 0);
194
+ const MAX_BAR = 20;
195
+ const lines = [];
196
+ for (const [groupIdx, group] of groups.entries()) {
197
+ const isCurrentRun = group.runId === currentRun?.id;
198
+ const isLastGroup = groupIdx === groups.length - 1;
199
+ // Run header for multi-run display
200
+ if (multiRun) {
201
+ if (groupIdx > 0)
202
+ lines.push('');
203
+ const runLabel = `Run #${groupIdx + 1}`;
204
+ // Check if this run started with a resume event
205
+ const firstEvent = group.timings[0];
206
+ const isResumed = firstEvent && firstEvent.phase === 'run:resumed';
207
+ const suffix = isResumed ? ' (resumed)' : '';
208
+ lines.push(colors.muted(` ${runLabel}${suffix}`));
209
+ }
210
+ // Determine if this run is in a terminal state (for the crashed bar display)
211
+ const isCrashed = isCurrentRun &&
212
+ currentRun &&
213
+ (currentRun.status === 'running' || currentRun.status === 'pending') &&
214
+ currentRun.pid != null &&
215
+ !isProcessAlive(currentRun.pid);
216
+ // For non-current runs, they are always terminal (otherwise they'd be current)
217
+ const isRunTerminal = !isCurrentRun ||
218
+ isCrashed ||
219
+ currentRun?.status === 'interrupted' ||
220
+ currentRun?.status === 'failed' ||
221
+ currentRun?.status === 'cancelled';
222
+ const isWaiting = isCurrentRun && currentRun?.status === 'waiting_approval';
223
+ for (const [idx, t] of group.timings.entries()) {
224
+ const isLast = isLastGroup && idx === group.timings.length - 1;
225
+ // Lifecycle event - render as a marker line
226
+ if (isLifecycleEvent(t.phase)) {
227
+ const event = LIFECYCLE_EVENTS[t.phase];
228
+ if (event) {
229
+ const sym = t.phase === 'run:completed'
230
+ ? symbols.success
231
+ : t.phase === 'run:failed' || t.phase === 'run:crashed'
232
+ ? symbols.error
233
+ : t.phase === 'run:stopped'
234
+ ? symbols.warning
235
+ : symbols.info;
236
+ lines.push(` ${event.color(`${sym} ${event.label}`)}`);
237
+ }
238
+ continue;
239
+ }
240
+ // Regular node phase
241
+ const rendered = renderNodeTiming(t, isLast, isWaiting ?? false, isRunTerminal ?? false, isCurrentRun ? currentRun : null, maxDurationMs, MAX_BAR);
242
+ lines.push(...rendered);
243
+ }
244
+ }
245
+ // Summary totals (across all runs, excluding lifecycle events)
246
+ const totalExecMs = nodeTimings.reduce((sum, t) => sum + (t.durationMs != null ? Number(t.durationMs) : 0), 0);
247
+ const totalWaitMs = nodeTimings.reduce((sum, t) => sum + (t.approvalWaitMs != null ? Number(t.approvalWaitMs) : 0), 0);
248
+ if (totalExecMs > 0) {
249
+ lines.push('');
250
+ lines.push(`${'Total execution'.padEnd(16)} ${formatDuration(totalExecMs)}`);
251
+ if (totalWaitMs > 0) {
252
+ lines.push(`${'Total wait'.padEnd(16)} ${formatDuration(totalWaitMs)}`);
253
+ lines.push(`${'Total wall-clock'.padEnd(16)} ${formatDuration(totalExecMs + totalWaitMs)}`);
254
+ }
255
+ }
256
+ return lines;
257
+ }
84
258
  export function createShowCommand() {
85
259
  return new Command('show')
86
260
  .description('Show feature details')
@@ -92,7 +266,8 @@ export function createShowCommand() {
92
266
  const timingRepo = container.resolve('IPhaseTimingRepository');
93
267
  const feature = await useCase.execute(featureId);
94
268
  const run = feature.agentRunId ? await runRepo.findById(feature.agentRunId) : null;
95
- const timings = feature.agentRunId ? await timingRepo.findByRunId(feature.agentRunId) : [];
269
+ // Fetch timings across ALL runs for the feature (not just the current run)
270
+ const timings = await timingRepo.findByFeatureId(feature.id);
96
271
  const worktreePath = feature.worktreePath ?? computeWorktreePath(feature.repositoryPath, feature.branch);
97
272
  const formatTs = (ts) => {
98
273
  if (ts instanceof Date)
@@ -117,59 +292,7 @@ export function createShowCommand() {
117
292
  });
118
293
  }
119
294
  if (timings.length > 0) {
120
- const isWaiting = run?.status === 'waiting_approval';
121
- const maxDurationMs = Math.max(...timings.map((t) => (t.durationMs != null ? Number(t.durationMs) : 0)));
122
- const MAX_BAR = 20;
123
- const lines = [];
124
- for (const [idx, t] of timings.entries()) {
125
- const isLast = idx === timings.length - 1;
126
- const isSubPhase = t.phase.includes(':');
127
- const label = isSubPhase
128
- ? ` \u21b3 ${t.phase.split(':')[1]}`.padEnd(16)
129
- : (NODE_TO_PHASE[t.phase] ?? t.phase).padEnd(16);
130
- // Completed phase - green bar with duration
131
- if (t.completedAt && t.durationMs != null) {
132
- const ms = Number(t.durationMs);
133
- const secs = (ms / 1000).toFixed(1);
134
- const barLen = maxDurationMs > 0 ? Math.max(1, Math.round((ms / maxDurationMs) * MAX_BAR)) : 1;
135
- const bar = `${colors.success('\u2588'.repeat(barLen))}${colors.muted('\u2591'.repeat(MAX_BAR - barLen))}`;
136
- lines.push(`${label} ${bar} ${secs}s`);
137
- }
138
- // Waiting for approval - only the LAST timing
139
- else if (isLast && isWaiting) {
140
- lines.push(`${label} ${colors.warning('awaiting review')}`);
141
- }
142
- // Running phase - blue bar with elapsed time
143
- else {
144
- const elapsedMs = Math.max(0, Date.now() - new Date(t.startedAt).getTime());
145
- const secs = (elapsedMs / 1000).toFixed(1);
146
- const barLen = maxDurationMs > 0
147
- ? Math.min(MAX_BAR, Math.max(1, Math.round((elapsedMs / maxDurationMs) * MAX_BAR)))
148
- : 1;
149
- const bar = `${colors.info('\u2588'.repeat(barLen))}${colors.muted('\u2591'.repeat(MAX_BAR - barLen))}`;
150
- lines.push(`${label} ${bar} ${secs}s (running)`);
151
- }
152
- // Show approval wait time under gated phases
153
- if (t.approvalWaitMs != null && Number(t.approvalWaitMs) > 0) {
154
- const waitMs = Number(t.approvalWaitMs);
155
- const waitSecs = (waitMs / 1000).toFixed(1);
156
- const waitLabel = ' \u21b3 approval'.padEnd(16);
157
- const waitBarLen = maxDurationMs > 0 ? Math.max(1, Math.round((waitMs / maxDurationMs) * MAX_BAR)) : 1;
158
- const waitBar = `${colors.warning('\u2588'.repeat(waitBarLen))}${colors.muted('\u2591'.repeat(MAX_BAR - waitBarLen))}`;
159
- lines.push(`${waitLabel} ${waitBar} ${waitSecs}s`);
160
- }
161
- }
162
- // Summary totals
163
- const totalExecMs = timings.reduce((sum, t) => sum + (t.durationMs != null ? Number(t.durationMs) : 0), 0);
164
- const totalWaitMs = timings.reduce((sum, t) => sum + (t.approvalWaitMs != null ? Number(t.approvalWaitMs) : 0), 0);
165
- if (totalExecMs > 0) {
166
- lines.push('');
167
- lines.push(`${'Total execution'.padEnd(16)} ${formatDuration(totalExecMs)}`);
168
- if (totalWaitMs > 0) {
169
- lines.push(`${'Total wait'.padEnd(16)} ${formatDuration(totalWaitMs)}`);
170
- lines.push(`${'Total wall-clock'.padEnd(16)} ${formatDuration(totalExecMs + totalWaitMs)}`);
171
- }
172
- }
295
+ const lines = renderPhaseTimings(timings, run);
173
296
  textBlocks.push({ title: 'Phase Timing', content: lines.join('\n') });
174
297
  }
175
298
  if (run?.status === 'waiting_approval') {