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.
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +28 -0
- package/src/__tests__/app-bundler.test.ts +12 -33
- package/src/__tests__/browser-skill-endstate.test.ts +1 -5
- package/src/__tests__/call-orchestrator.test.ts +328 -0
- package/src/__tests__/call-state.test.ts +133 -0
- package/src/__tests__/call-store.test.ts +476 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +409 -0
- package/src/__tests__/config-schema.test.ts +49 -0
- package/src/__tests__/doordash-session.test.ts +9 -0
- package/src/__tests__/ipc-snapshot.test.ts +34 -0
- package/src/__tests__/registry.test.ts +13 -8
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +218 -0
- package/src/__tests__/run-orchestrator.test.ts +3 -3
- package/src/__tests__/runtime-attachment-metadata.test.ts +17 -19
- package/src/__tests__/runtime-runs-http.test.ts +1 -19
- package/src/__tests__/runtime-runs.test.ts +7 -7
- package/src/__tests__/session-queue.test.ts +50 -0
- package/src/__tests__/turn-commit.test.ts +56 -0
- package/src/__tests__/workspace-git-service.test.ts +217 -0
- package/src/__tests__/workspace-heartbeat-service.test.ts +129 -0
- package/src/bundler/app-bundler.ts +29 -12
- package/src/calls/call-constants.ts +10 -0
- package/src/calls/call-orchestrator.ts +364 -0
- package/src/calls/call-state.ts +64 -0
- package/src/calls/call-store.ts +229 -0
- package/src/calls/relay-server.ts +298 -0
- package/src/calls/twilio-config.ts +34 -0
- package/src/calls/twilio-provider.ts +169 -0
- package/src/calls/twilio-routes.ts +236 -0
- package/src/calls/types.ts +37 -0
- package/src/calls/voice-provider.ts +14 -0
- package/src/cli/doordash.ts +5 -24
- package/src/config/bundled-skills/doordash/SKILL.md +104 -0
- package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -1
- package/src/config/defaults.ts +11 -0
- package/src/config/schema.ts +57 -0
- package/src/config/system-prompt.ts +50 -1
- package/src/config/types.ts +1 -0
- package/src/daemon/handlers/config.ts +30 -0
- package/src/daemon/handlers/index.ts +6 -0
- package/src/daemon/handlers/work-items.ts +142 -2
- package/src/daemon/ipc-contract-inventory.json +12 -0
- package/src/daemon/ipc-contract.ts +52 -0
- package/src/daemon/lifecycle.ts +27 -5
- package/src/daemon/server.ts +10 -12
- package/src/daemon/session-tool-setup.ts +6 -0
- package/src/daemon/session.ts +40 -1
- package/src/index.ts +2 -0
- package/src/media/gemini-image-service.ts +1 -1
- package/src/memory/db.ts +266 -0
- package/src/memory/schema.ts +42 -0
- package/src/runtime/http-server.ts +189 -25
- package/src/runtime/http-types.ts +0 -2
- package/src/runtime/routes/attachment-routes.ts +6 -6
- package/src/runtime/routes/channel-routes.ts +16 -18
- package/src/runtime/routes/conversation-routes.ts +5 -9
- package/src/runtime/routes/run-routes.ts +4 -8
- package/src/runtime/run-orchestrator.ts +32 -5
- package/src/tools/calls/call-end.ts +117 -0
- package/src/tools/calls/call-start.ts +134 -0
- package/src/tools/calls/call-status.ts +97 -0
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/registry.ts +2 -4
- package/src/tools/tasks/index.ts +2 -0
- package/src/tools/tasks/task-delete.ts +49 -8
- package/src/tools/tasks/task-run.ts +9 -1
- package/src/tools/tasks/work-item-enqueue.ts +93 -3
- package/src/tools/tasks/work-item-list.ts +10 -25
- package/src/tools/tasks/work-item-remove.ts +112 -0
- package/src/tools/tasks/work-item-update.ts +186 -0
- package/src/tools/tool-manifest.ts +39 -31
- package/src/tools/ui-surface/definitions.ts +3 -0
- package/src/work-items/work-item-store.ts +209 -0
- package/src/workspace/commit-message-enrichment-service.ts +260 -0
- package/src/workspace/commit-message-provider.ts +95 -0
- package/src/workspace/git-service.ts +187 -32
- package/src/workspace/heartbeat-service.ts +70 -13
- 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
|
-
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
443
|
+
const status = await this.getStatusInternal();
|
|
444
|
+
if (status.clean) {
|
|
445
|
+
return { committed: false, status, didRunGit: true as const };
|
|
446
|
+
}
|
|
366
447
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
386
|
-
|
|
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
|
-
*
|
|
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:
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
66
|
+
const ctx: CommitContext = {
|
|
67
|
+
workspaceDir,
|
|
68
|
+
trigger: 'turn',
|
|
97
69
|
sessionId,
|
|
98
70
|
turnNumber,
|
|
99
|
-
|
|
100
|
-
|
|
71
|
+
changedFiles: uniqueFiles,
|
|
72
|
+
timestampMs: Date.now(),
|
|
101
73
|
};
|
|
102
74
|
|
|
103
|
-
return
|
|
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
|