vellum 0.2.0 → 0.2.1

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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +28 -0
  3. package/src/__tests__/app-bundler.test.ts +12 -33
  4. package/src/__tests__/browser-skill-endstate.test.ts +1 -5
  5. package/src/__tests__/call-orchestrator.test.ts +328 -0
  6. package/src/__tests__/call-state.test.ts +133 -0
  7. package/src/__tests__/call-store.test.ts +476 -0
  8. package/src/__tests__/commit-message-enrichment-service.test.ts +409 -0
  9. package/src/__tests__/config-schema.test.ts +49 -0
  10. package/src/__tests__/doordash-session.test.ts +9 -0
  11. package/src/__tests__/ipc-snapshot.test.ts +34 -0
  12. package/src/__tests__/registry.test.ts +13 -8
  13. package/src/__tests__/run-orchestrator-assistant-events.test.ts +218 -0
  14. package/src/__tests__/run-orchestrator.test.ts +3 -3
  15. package/src/__tests__/runtime-attachment-metadata.test.ts +17 -19
  16. package/src/__tests__/runtime-runs-http.test.ts +1 -19
  17. package/src/__tests__/runtime-runs.test.ts +7 -7
  18. package/src/__tests__/session-queue.test.ts +50 -0
  19. package/src/__tests__/turn-commit.test.ts +56 -0
  20. package/src/__tests__/workspace-git-service.test.ts +217 -0
  21. package/src/__tests__/workspace-heartbeat-service.test.ts +129 -0
  22. package/src/bundler/app-bundler.ts +29 -12
  23. package/src/calls/call-constants.ts +10 -0
  24. package/src/calls/call-orchestrator.ts +364 -0
  25. package/src/calls/call-state.ts +64 -0
  26. package/src/calls/call-store.ts +229 -0
  27. package/src/calls/relay-server.ts +298 -0
  28. package/src/calls/twilio-config.ts +34 -0
  29. package/src/calls/twilio-provider.ts +169 -0
  30. package/src/calls/twilio-routes.ts +236 -0
  31. package/src/calls/types.ts +37 -0
  32. package/src/calls/voice-provider.ts +14 -0
  33. package/src/cli/doordash.ts +5 -24
  34. package/src/config/bundled-skills/doordash/SKILL.md +104 -0
  35. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
  36. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -1
  37. package/src/config/defaults.ts +11 -0
  38. package/src/config/schema.ts +57 -0
  39. package/src/config/system-prompt.ts +50 -1
  40. package/src/config/types.ts +1 -0
  41. package/src/daemon/handlers/config.ts +30 -0
  42. package/src/daemon/handlers/index.ts +6 -0
  43. package/src/daemon/handlers/work-items.ts +142 -2
  44. package/src/daemon/ipc-contract-inventory.json +12 -0
  45. package/src/daemon/ipc-contract.ts +52 -0
  46. package/src/daemon/lifecycle.ts +27 -5
  47. package/src/daemon/server.ts +10 -12
  48. package/src/daemon/session-tool-setup.ts +6 -0
  49. package/src/daemon/session.ts +40 -1
  50. package/src/index.ts +2 -0
  51. package/src/media/gemini-image-service.ts +1 -1
  52. package/src/memory/db.ts +266 -0
  53. package/src/memory/schema.ts +42 -0
  54. package/src/runtime/http-server.ts +189 -25
  55. package/src/runtime/http-types.ts +0 -2
  56. package/src/runtime/routes/attachment-routes.ts +6 -6
  57. package/src/runtime/routes/channel-routes.ts +16 -18
  58. package/src/runtime/routes/conversation-routes.ts +5 -9
  59. package/src/runtime/routes/run-routes.ts +4 -8
  60. package/src/runtime/run-orchestrator.ts +32 -5
  61. package/src/tools/calls/call-end.ts +117 -0
  62. package/src/tools/calls/call-start.ts +134 -0
  63. package/src/tools/calls/call-status.ts +97 -0
  64. package/src/tools/credentials/vault.ts +1 -1
  65. package/src/tools/registry.ts +2 -4
  66. package/src/tools/tasks/index.ts +2 -0
  67. package/src/tools/tasks/task-delete.ts +49 -8
  68. package/src/tools/tasks/task-run.ts +9 -1
  69. package/src/tools/tasks/work-item-enqueue.ts +93 -3
  70. package/src/tools/tasks/work-item-list.ts +10 -25
  71. package/src/tools/tasks/work-item-remove.ts +112 -0
  72. package/src/tools/tasks/work-item-update.ts +186 -0
  73. package/src/tools/tool-manifest.ts +39 -31
  74. package/src/tools/ui-surface/definitions.ts +3 -0
  75. package/src/work-items/work-item-store.ts +209 -0
  76. package/src/workspace/commit-message-enrichment-service.ts +260 -0
  77. package/src/workspace/commit-message-provider.ts +95 -0
  78. package/src/workspace/git-service.ts +187 -32
  79. package/src/workspace/heartbeat-service.ts +70 -13
  80. package/src/workspace/turn-commit.ts +39 -49
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import { getLogger } from '../util/logger.js';
6
+ import { getConfig } from '../config/loader.js';
6
7
 
7
8
  const execFileAsync = promisify(execFile);
8
9
  const log = getLogger('workspace-git');
@@ -142,12 +143,50 @@ export class WorkspaceGitService {
142
143
  private readonly mutex: Mutex;
143
144
  private initialized = false;
144
145
  private initPromise: Promise<void> | null = null;
146
+ private consecutiveFailures = 0;
147
+ private nextAllowedAttemptMs = 0;
145
148
 
146
149
  constructor(workspaceDir: string) {
147
150
  this.workspaceDir = workspaceDir;
148
151
  this.mutex = new Mutex();
149
152
  }
150
153
 
154
+ /**
155
+ * Check if the circuit breaker is open (too many recent failures).
156
+ * When open, commit attempts are skipped until the backoff window expires.
157
+ */
158
+ private isBreakerOpen(): boolean {
159
+ if (this.consecutiveFailures === 0) return false;
160
+ return Date.now() < this.nextAllowedAttemptMs;
161
+ }
162
+
163
+ private recordSuccess(): void {
164
+ if (this.consecutiveFailures > 0) {
165
+ log.info(
166
+ { workspaceDir: this.workspaceDir, previousFailures: this.consecutiveFailures },
167
+ 'Circuit breaker closed: commit succeeded after failures',
168
+ );
169
+ }
170
+ this.consecutiveFailures = 0;
171
+ this.nextAllowedAttemptMs = 0;
172
+ }
173
+
174
+ private recordFailure(): void {
175
+ const config = getConfig();
176
+ const failureBackoffBaseMs = config.workspaceGit?.failureBackoffBaseMs ?? 2000;
177
+ const failureBackoffMaxMs = config.workspaceGit?.failureBackoffMaxMs ?? 60000;
178
+ this.consecutiveFailures++;
179
+ const delay = Math.min(
180
+ failureBackoffBaseMs * Math.pow(2, this.consecutiveFailures - 1),
181
+ failureBackoffMaxMs,
182
+ );
183
+ this.nextAllowedAttemptMs = Date.now() + delay;
184
+ log.warn(
185
+ { workspaceDir: this.workspaceDir, consecutiveFailures: this.consecutiveFailures, backoffMs: delay },
186
+ 'Circuit breaker opened: commit failed, backing off',
187
+ );
188
+ }
189
+
151
190
  /**
152
191
  * Ensure the git repository is initialized.
153
192
  * Idempotent: safe to call multiple times.
@@ -344,47 +383,119 @@ export class WorkspaceGitService {
344
383
  *
345
384
  * @param decide - Called with the current status. Return an object with `message`
346
385
  * (and optional `metadata`) to commit, or `null` to skip.
386
+ * @param options.bypassBreaker - Skip circuit breaker checks (used for shutdown commits).
387
+ * @param options.deadlineMs - Absolute timestamp (Date.now()) after which the commit
388
+ * should be skipped. Checked before lock acquisition, after lock acquisition, and
389
+ * before git add/commit to prevent stale queued attempts from doing expensive work.
347
390
  * @returns Whether a commit was created and the status at check time.
348
391
  */
349
392
  async commitIfDirty(
350
393
  decide: (status: GitStatus) => { message: string; metadata?: GitCommitMetadata } | null,
394
+ options?: { bypassBreaker?: boolean; deadlineMs?: number },
351
395
  ): Promise<{ committed: boolean; status: GitStatus }> {
396
+ const emptyStatus: GitStatus = { staged: [], modified: [], untracked: [], clean: false };
397
+
398
+ // Circuit breaker: skip expensive git work if recent attempts have been failing.
399
+ // Shutdown commits bypass the breaker because the process is about to exit and
400
+ // this is the last chance to persist workspace state.
401
+ if (!options?.bypassBreaker && this.isBreakerOpen()) {
402
+ log.debug(
403
+ { workspaceDir: this.workspaceDir, consecutiveFailures: this.consecutiveFailures },
404
+ 'Circuit breaker open, skipping commit attempt',
405
+ );
406
+ return { committed: false, status: emptyStatus };
407
+ }
408
+
409
+ // Deadline fast-path: bail before acquiring the lock if already past deadline.
410
+ if (isDeadlineExpired(options?.deadlineMs)) {
411
+ log.debug(
412
+ { workspaceDir: this.workspaceDir },
413
+ 'Deadline expired before lock acquisition, skipping commit',
414
+ );
415
+ return { committed: false, status: emptyStatus };
416
+ }
417
+
352
418
  await this.ensureInitialized();
353
419
 
354
- return this.mutex.withLock(async () => {
355
- const status = await this.getStatusInternal();
356
- if (status.clean) {
357
- return { committed: false, status };
358
- }
420
+ try {
421
+ const result = await this.mutex.withLock(async () => {
422
+ // Re-check breaker under lock: a queued call that started before the
423
+ // breaker opened should not proceed with expensive git work now that
424
+ // the breaker is open.
425
+ if (!options?.bypassBreaker && this.isBreakerOpen()) {
426
+ log.debug(
427
+ { workspaceDir: this.workspaceDir, consecutiveFailures: this.consecutiveFailures },
428
+ 'Circuit breaker open after lock acquisition, skipping commit',
429
+ );
430
+ return { committed: false, status: emptyStatus, didRunGit: false as const };
431
+ }
359
432
 
360
- const decision = decide(status);
361
- if (!decision) {
362
- return { committed: false, status };
363
- }
433
+ // Re-check deadline after lock acquisition: the call may have waited
434
+ // in the mutex queue past its deadline.
435
+ if (isDeadlineExpired(options?.deadlineMs)) {
436
+ log.debug(
437
+ { workspaceDir: this.workspaceDir },
438
+ 'Deadline expired after lock acquisition, skipping commit',
439
+ );
440
+ return { committed: false, status: emptyStatus, didRunGit: false as const };
441
+ }
364
442
 
365
- await this.execGit(['add', '-A']);
443
+ const status = await this.getStatusInternal();
444
+ if (status.clean) {
445
+ return { committed: false, status, didRunGit: true as const };
446
+ }
366
447
 
367
- // Verify something was actually staged. Another service instance
368
- // (or external process) could have committed between our status
369
- // check and the add, leaving the index clean.
370
- try {
371
- await this.execGit(['diff', '--cached', '--quiet']);
372
- // Exit code 0 means nothing staged — nothing to commit
373
- return { committed: false, status };
374
- } catch {
375
- // Exit code 1 means there ARE staged changes — proceed
376
- }
448
+ const decision = decide(status);
449
+ if (!decision) {
450
+ return { committed: false, status, didRunGit: true as const };
451
+ }
377
452
 
378
- let fullMessage = decision.message;
379
- if (decision.metadata && Object.keys(decision.metadata).length > 0) {
380
- fullMessage += '\n\n' + Object.entries(decision.metadata)
381
- .map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
382
- .join('\n');
383
- }
453
+ // Check deadline before expensive git add/commit operations.
454
+ if (isDeadlineExpired(options?.deadlineMs)) {
455
+ log.debug(
456
+ { workspaceDir: this.workspaceDir },
457
+ 'Deadline expired before git add/commit, skipping commit',
458
+ );
459
+ return { committed: false, status, didRunGit: true as const };
460
+ }
384
461
 
385
- await this.execGit(['commit', '-m', fullMessage]);
386
- return { committed: true, status };
387
- });
462
+ await this.execGit(['add', '-A']);
463
+
464
+ // Verify something was actually staged. Another service instance
465
+ // (or external process) could have committed between our status
466
+ // check and the add, leaving the index clean.
467
+ try {
468
+ await this.execGit(['diff', '--cached', '--quiet']);
469
+ // Exit code 0 means nothing staged — nothing to commit
470
+ return { committed: false, status, didRunGit: true as const };
471
+ } catch (err) {
472
+ // git diff --cached --quiet exits with code 1 when there are staged changes.
473
+ // Any other error (timeout, permission, etc.) should be treated as a failure.
474
+ const execErr = err as ExecError;
475
+ if (execErr.code !== 1) {
476
+ throw err;
477
+ }
478
+ // Exit code 1 = staged changes exist — proceed with commit
479
+ }
480
+
481
+ let fullMessage = decision.message;
482
+ if (decision.metadata && Object.keys(decision.metadata).length > 0) {
483
+ fullMessage += '\n\n' + Object.entries(decision.metadata)
484
+ .map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
485
+ .join('\n');
486
+ }
487
+
488
+ await this.execGit(['commit', '-m', fullMessage]);
489
+ return { committed: true, status, didRunGit: true as const };
490
+ });
491
+ if (result.didRunGit) {
492
+ this.recordSuccess();
493
+ }
494
+ return { committed: result.committed, status: result.status };
495
+ } catch (err) {
496
+ this.recordFailure();
497
+ throw err;
498
+ }
388
499
  }
389
500
 
390
501
  /**
@@ -532,15 +643,19 @@ export class WorkspaceGitService {
532
643
 
533
644
  /**
534
645
  * Execute a git command in the workspace directory.
535
- * Includes a 30-second timeout to prevent hung operations
536
- * (e.g. stale git lock files).
646
+ * Uses the configurable interactiveGitTimeoutMs (default 10 000 ms) to
647
+ * prevent hung operations (e.g. stale git lock files). The timeout is
648
+ * intentionally short for interactive workspace operations — background
649
+ * enrichment jobs use their own dedicated timeout.
537
650
  */
538
651
  private async execGit(args: string[]): Promise<{ stdout: string; stderr: string }> {
652
+ const config = getConfig();
653
+ const timeoutMs = config.workspaceGit?.interactiveGitTimeoutMs ?? 10_000;
539
654
  try {
540
655
  const { stdout, stderr } = await execFileAsync('git', args, {
541
656
  cwd: this.workspaceDir,
542
657
  encoding: 'utf-8',
543
- timeout: 30_000,
658
+ timeout: timeoutMs,
544
659
  env: cleanGitEnv(this.workspaceDir),
545
660
  });
546
661
  return { stdout, stderr };
@@ -567,6 +682,23 @@ export class WorkspaceGitService {
567
682
  }
568
683
  }
569
684
 
685
+ /**
686
+ * Get the commit hash of the current HEAD.
687
+ * This is a lightweight read-only operation that does not require the mutex.
688
+ */
689
+ async getHeadHash(): Promise<string> {
690
+ const { stdout } = await this.execGit(['rev-parse', 'HEAD']);
691
+ return stdout.trim();
692
+ }
693
+
694
+ /**
695
+ * Write a git note to a specific commit.
696
+ * Uses the 'vellum' notes ref to avoid conflicts with default notes.
697
+ */
698
+ async writeNote(commitHash: string, noteContent: string): Promise<void> {
699
+ await this.execGit(['notes', '--ref=vellum', 'add', '-f', '-m', noteContent, commitHash]);
700
+ }
701
+
570
702
  /**
571
703
  * Check if the workspace has a git repository initialized.
572
704
  * This is a non-blocking check that doesn't trigger initialization.
@@ -583,6 +715,14 @@ export class WorkspaceGitService {
583
715
  }
584
716
  }
585
717
 
718
+ /**
719
+ * Check whether a deadline has expired.
720
+ * Returns true when `deadlineMs` is provided and `Date.now()` has reached or passed it.
721
+ */
722
+ export function isDeadlineExpired(deadlineMs?: number): boolean {
723
+ return deadlineMs !== undefined && Date.now() >= deadlineMs;
724
+ }
725
+
586
726
  /**
587
727
  * Singleton registry for workspace git services.
588
728
  * Ensures one service instance per workspace directory.
@@ -618,3 +758,18 @@ export function getAllWorkspaceGitServices(): ReadonlyMap<string, WorkspaceGitSe
618
758
  export function _resetGitServiceRegistry(): void {
619
759
  serviceRegistry.clear();
620
760
  }
761
+
762
+ /**
763
+ * @internal Test-only: reset circuit breaker state for a service instance
764
+ */
765
+ export function _resetBreaker(service: WorkspaceGitService): void {
766
+ (service as unknown as { consecutiveFailures: number }).consecutiveFailures = 0;
767
+ (service as unknown as { nextAllowedAttemptMs: number }).nextAllowedAttemptMs = 0;
768
+ }
769
+
770
+ /**
771
+ * @internal Test-only: get consecutive failure count
772
+ */
773
+ export function _getConsecutiveFailures(service: WorkspaceGitService): number {
774
+ return (service as unknown as { consecutiveFailures: number }).consecutiveFailures;
775
+ }
@@ -1,5 +1,11 @@
1
1
  import { getLogger } from '../util/logger.js';
2
2
  import { getAllWorkspaceGitServices, type WorkspaceGitService } from './git-service.js';
3
+ import {
4
+ DefaultCommitMessageProvider,
5
+ type CommitContext,
6
+ type CommitMessageProvider,
7
+ } from './commit-message-provider.js';
8
+ import { getEnrichmentService } from './commit-message-enrichment-service.js';
3
9
 
4
10
  const log = getLogger('heartbeat');
5
11
 
@@ -23,6 +29,8 @@ export interface HeartbeatServiceOptions {
23
29
  getServices?: () => ReadonlyMap<string, WorkspaceGitService>;
24
30
  /** Override for getting the current timestamp (for testing). */
25
31
  now?: () => number;
32
+ /** Custom commit message provider. */
33
+ commitMessageProvider?: CommitMessageProvider;
26
34
  }
27
35
 
28
36
  /**
@@ -61,6 +69,7 @@ export class HeartbeatService {
61
69
  private readonly intervalMs: number;
62
70
  private readonly getServices: () => ReadonlyMap<string, WorkspaceGitService>;
63
71
  private readonly now: () => number;
72
+ private readonly commitMessageProvider: CommitMessageProvider;
64
73
  private timer: ReturnType<typeof setInterval> | null = null;
65
74
  /** Tracks the currently in-flight check to prevent overlapping runs and allow clean shutdown. */
66
75
  private activeCheck: Promise<HeartbeatCheckResult> | null = null;
@@ -71,6 +80,7 @@ export class HeartbeatService {
71
80
  this.intervalMs = options?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
72
81
  this.getServices = options?.getServices ?? getAllWorkspaceGitServices;
73
82
  this.now = options?.now ?? Date.now;
83
+ this.commitMessageProvider = options?.commitMessageProvider ?? new DefaultCommitMessageProvider();
74
84
  }
75
85
 
76
86
  /**
@@ -139,7 +149,7 @@ export class HeartbeatService {
139
149
  result.checked++;
140
150
 
141
151
  try {
142
- const committed = await this.checkWorkspace(workspaceDir, service, 'heartbeat');
152
+ const committed = await this.checkWorkspace(workspaceDir, service);
143
153
  if (committed) {
144
154
  result.committed++;
145
155
  } else {
@@ -189,18 +199,39 @@ export class HeartbeatService {
189
199
 
190
200
  try {
191
201
  const now = this.now();
192
- const { committed } = await service.commitIfDirty((status) => {
193
- const totalChanges = new Set([...status.staged, ...status.modified, ...status.untracked]).size;
194
- log.info({ workspaceDir, totalChanges }, 'Committing pending changes on shutdown');
195
- return {
196
- message: `auto-commit: shutdown safety net (${totalChanges} files)`,
197
- metadata: { trigger: 'shutdown', timestamp: now },
202
+ let shutdownFiles: string[] = [];
203
+ const { committed } = await service.commitIfDirty((st) => {
204
+ const uniqueFiles = [...new Set([...st.staged, ...st.modified, ...st.untracked])];
205
+ shutdownFiles = uniqueFiles;
206
+ log.info({ workspaceDir, totalChanges: uniqueFiles.length }, 'Committing pending changes on shutdown');
207
+
208
+ const ctx: CommitContext = {
209
+ workspaceDir,
210
+ trigger: 'shutdown',
211
+ changedFiles: uniqueFiles,
212
+ timestampMs: now,
198
213
  };
199
- });
214
+
215
+ return this.commitMessageProvider.buildImmediateMessage(ctx);
216
+ }, { bypassBreaker: true });
200
217
 
201
218
  if (committed) {
202
219
  firstSeenDirty.delete(workspaceDir);
203
220
  result.committed++;
221
+
222
+ // Fire-and-forget enrichment
223
+ try {
224
+ const commitHash = await service.getHeadHash();
225
+ const shutdownCtx: CommitContext = {
226
+ workspaceDir,
227
+ trigger: 'shutdown',
228
+ changedFiles: shutdownFiles,
229
+ timestampMs: this.now(),
230
+ };
231
+ getEnrichmentService().enqueue({ workspaceDir, commitHash, context: shutdownCtx, gitService: service });
232
+ } catch (enrichErr) {
233
+ log.debug({ enrichErr }, 'Failed to enqueue shutdown enrichment (non-fatal)');
234
+ }
204
235
  } else {
205
236
  result.skipped++;
206
237
  }
@@ -225,13 +256,15 @@ export class HeartbeatService {
225
256
  private async checkWorkspace(
226
257
  workspaceDir: string,
227
258
  service: WorkspaceGitService,
228
- trigger: string,
229
259
  ): Promise<boolean> {
230
260
  const now = this.now();
261
+ let heartbeatFiles: string[] = [];
262
+ let heartbeatReason: string | undefined;
231
263
 
232
264
  // Atomic status check + conditional commit within a single mutex lock.
233
265
  const { committed, status } = await service.commitIfDirty((st) => {
234
- const totalChanges = new Set([...st.staged, ...st.modified, ...st.untracked]).size;
266
+ const uniqueFiles = [...new Set([...st.staged, ...st.modified, ...st.untracked])];
267
+ const totalChanges = uniqueFiles.length;
235
268
 
236
269
  // Track when we first saw this workspace as dirty
237
270
  if (!firstSeenDirty.has(workspaceDir)) {
@@ -256,19 +289,43 @@ export class HeartbeatService {
256
289
  ? `changes older than ${Math.round(dirtyAge / 1000)}s`
257
290
  : `${totalChanges} files changed (threshold: ${this.fileThreshold})`;
258
291
 
292
+ heartbeatFiles = uniqueFiles;
293
+ heartbeatReason = reason;
294
+
259
295
  log.info(
260
296
  { workspaceDir, totalChanges, dirtyAgeMs: dirtyAge, reason },
261
297
  'Heartbeat auto-committing workspace changes',
262
298
  );
263
299
 
264
- return {
265
- message: `auto-commit: ${trigger} safety net (${totalChanges} files, ${reason})`,
266
- metadata: { trigger, timestamp: now },
300
+ const ctx: CommitContext = {
301
+ workspaceDir,
302
+ trigger: 'heartbeat',
303
+ changedFiles: uniqueFiles,
304
+ timestampMs: now,
305
+ reason,
267
306
  };
307
+
308
+ return this.commitMessageProvider.buildImmediateMessage(ctx);
268
309
  });
269
310
 
270
311
  if (committed) {
271
312
  firstSeenDirty.delete(workspaceDir);
313
+
314
+ // Fire-and-forget enrichment
315
+ try {
316
+ const commitHash = await service.getHeadHash();
317
+ const hbCtx: CommitContext = {
318
+ workspaceDir,
319
+ trigger: 'heartbeat',
320
+ changedFiles: heartbeatFiles,
321
+ timestampMs: now,
322
+ reason: heartbeatReason,
323
+ };
324
+ getEnrichmentService().enqueue({ workspaceDir, commitHash, context: hbCtx, gitService: service });
325
+ } catch (enrichErr) {
326
+ log.debug({ enrichErr }, 'Failed to enqueue heartbeat enrichment (non-fatal)');
327
+ }
328
+
272
329
  return true;
273
330
  }
274
331
 
@@ -11,6 +11,12 @@
11
11
 
12
12
  import { getWorkspaceGitService } from './git-service.js';
13
13
  import { getLogger } from '../util/logger.js';
14
+ import {
15
+ DefaultCommitMessageProvider,
16
+ type CommitContext,
17
+ type CommitMessageProvider,
18
+ } from './commit-message-provider.js';
19
+ import { getEnrichmentService } from './commit-message-enrichment-service.js';
14
20
 
15
21
  const log = getLogger('turn-commit');
16
22
 
@@ -25,46 +31,6 @@ export interface TurnCommitMetadata {
25
31
  filesChanged: number;
26
32
  }
27
33
 
28
- /**
29
- * Build a commit message with structured metadata for a turn boundary commit.
30
- *
31
- * Format:
32
- * ```
33
- * Turn: <summary>
34
- *
35
- * Session: sess_xyz
36
- * Turn: 5
37
- * Timestamp: 2026-02-18T15:30:00Z
38
- * Files: 3 changed
39
- * ```
40
- */
41
- function buildCommitMessage(summary: string, metadata: TurnCommitMetadata): string {
42
- return [
43
- `Turn: ${summary}`,
44
- '',
45
- `Session: ${metadata.sessionId}`,
46
- `Turn: ${metadata.turnNumber}`,
47
- `Timestamp: ${metadata.timestamp}`,
48
- `Files: ${metadata.filesChanged} changed`,
49
- ].join('\n');
50
- }
51
-
52
- /**
53
- * Build a short summary of what changed from a list of file paths.
54
- */
55
- function buildChangeSummary(files: string[]): string {
56
- if (files.length === 0) {
57
- return 'workspace changes';
58
- }
59
- if (files.length === 1) {
60
- return files[0];
61
- }
62
- if (files.length <= 3) {
63
- return files.join(', ');
64
- }
65
- return `${files.slice(0, 2).join(', ')} and ${files.length - 2} more`;
66
- }
67
-
68
34
  /**
69
35
  * Attempt a turn-boundary commit for the workspace.
70
36
  *
@@ -77,40 +43,64 @@ function buildChangeSummary(files: string[]): string {
77
43
  * @param workspaceDir - Absolute path to the workspace directory
78
44
  * @param sessionId - Session/conversation identifier
79
45
  * @param turnNumber - 1-based turn number within the session
46
+ * @param provider - Optional commit message provider (defaults to deterministic)
47
+ * @param deadlineMs - Optional absolute deadline (Date.now()) after which the commit should be skipped
80
48
  */
81
49
  export async function commitTurnChanges(
82
50
  workspaceDir: string,
83
51
  sessionId: string,
84
52
  turnNumber: number,
53
+ provider?: CommitMessageProvider,
54
+ deadlineMs?: number,
85
55
  ): Promise<void> {
56
+ const messageProvider = provider ?? new DefaultCommitMessageProvider();
86
57
  try {
87
58
  const gitService = getWorkspaceGitService(workspaceDir);
59
+ const commitStartMs = Date.now();
88
60
 
89
61
  // Atomic status check + commit within a single mutex lock to prevent
90
62
  // TOCTOU races with heartbeat commits.
91
63
  const { committed, status } = await gitService.commitIfDirty((st) => {
92
64
  const uniqueFiles = [...new Set([...st.staged, ...st.modified, ...st.untracked])];
93
- const timestamp = new Date().toISOString();
94
- const summary = buildChangeSummary(uniqueFiles);
95
65
 
96
- const metadata: TurnCommitMetadata = {
66
+ const ctx: CommitContext = {
67
+ workspaceDir,
68
+ trigger: 'turn',
97
69
  sessionId,
98
70
  turnNumber,
99
- timestamp,
100
- filesChanged: uniqueFiles.length,
71
+ changedFiles: uniqueFiles,
72
+ timestampMs: Date.now(),
101
73
  };
102
74
 
103
- return { message: buildCommitMessage(summary, metadata) };
104
- });
75
+ return messageProvider.buildImmediateMessage(ctx);
76
+ }, deadlineMs !== undefined ? { deadlineMs } : undefined);
77
+
78
+ const commitDurationMs = Date.now() - commitStartMs;
105
79
 
106
80
  if (committed) {
107
81
  const uniqueFiles = [...new Set([...status.staged, ...status.modified, ...status.untracked])];
108
82
  log.info(
109
- { sessionId, turnNumber, filesChanged: uniqueFiles.length },
83
+ { sessionId, turnNumber, filesChanged: uniqueFiles.length, durationMs: commitDurationMs },
110
84
  'Turn-boundary commit created',
111
85
  );
86
+
87
+ // Fire-and-forget enrichment — never blocks turn completion
88
+ try {
89
+ const commitHash = await gitService.getHeadHash();
90
+ const ctx: CommitContext = {
91
+ workspaceDir,
92
+ trigger: 'turn',
93
+ sessionId,
94
+ turnNumber,
95
+ changedFiles: uniqueFiles,
96
+ timestampMs: Date.now(),
97
+ };
98
+ getEnrichmentService().enqueue({ workspaceDir, commitHash, context: ctx, gitService });
99
+ } catch (enrichErr) {
100
+ log.debug({ enrichErr }, 'Failed to enqueue enrichment (non-fatal)');
101
+ }
112
102
  } else {
113
- log.debug({ sessionId, turnNumber }, 'No workspace changes to commit for turn');
103
+ log.debug({ sessionId, turnNumber, durationMs: commitDurationMs }, 'No workspace changes to commit for turn');
114
104
  }
115
105
  } catch (err) {
116
106
  // Never let commit failures propagate — they must not affect the turn