@bevel-software/platform-core-backend 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/modules/workflow/git/git.service.d.ts +14 -0
  2. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  3. package/dist/modules/workflow/git/git.service.js +47 -1
  4. package/dist/modules/workflow/git/git.service.js.map +1 -1
  5. package/dist/modules/workflow/pending-commits.service.d.ts +17 -0
  6. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  7. package/dist/modules/workflow/pending-commits.service.js +33 -0
  8. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  9. package/dist/modules/workflow/workflow.errors.d.ts +26 -0
  10. package/dist/modules/workflow/workflow.errors.d.ts.map +1 -1
  11. package/dist/modules/workflow/workflow.errors.js +38 -0
  12. package/dist/modules/workflow/workflow.errors.js.map +1 -1
  13. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  14. package/dist/modules/workflow/workflow.routes.js +5 -2
  15. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  16. package/dist/modules/workflow/workflow.service.d.ts +25 -1
  17. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  18. package/dist/modules/workflow/workflow.service.js +75 -6
  19. package/dist/modules/workflow/workflow.service.js.map +1 -1
  20. package/package.json +2 -2
  21. package/src/modules/workflow/__tests__/pending-commits.enqueue-if-absent.test.ts +72 -0
  22. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +111 -2
  23. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +74 -0
  24. package/src/modules/workflow/git/git.service.ts +50 -0
  25. package/src/modules/workflow/pending-commits.service.ts +39 -0
  26. package/src/modules/workflow/workflow.errors.ts +41 -0
  27. package/src/modules/workflow/workflow.routes.ts +5 -2
  28. package/src/modules/workflow/workflow.service.ts +83 -5
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { PendingCommitsService } from '../pending-commits.service.js';
3
+ import type { Database } from '../../database/connection.js';
4
+
5
+ /**
6
+ * `enqueueIfAbsent` backs the pull-conflict recovery dispatch: it must
7
+ * insert only when NO row exists for the (workspace, branch, path) in ANY
8
+ * status. A plain `enqueue` would refresh an existing pending row and reset
9
+ * its retry counters — and the dispatch path fires on every sync attempt,
10
+ * so those resets would starve the worker's ladder and the recovery agent
11
+ * would never spawn. A `needs_attention` row must also block re-entry (the
12
+ * ladder already escalated to a human; more agent runs are just a loop).
13
+ *
14
+ * Fake drizzle chain, same shape as the vault tests — the queue's SQL-level
15
+ * behavior (claim fencing etc.) is covered elsewhere; this pins the
16
+ * check-then-insert decision logic.
17
+ */
18
+ function makeFakeDb(existingCount: number) {
19
+ const inserted: unknown[] = [];
20
+ const selectChain: any = {};
21
+ selectChain.from = vi.fn(() => selectChain);
22
+ selectChain.where = vi.fn(() => selectChain);
23
+ selectChain.then = (onF: any, onR: any) =>
24
+ Promise.resolve([{ count: existingCount }]).then(onF, onR);
25
+ const insertChain: any = {};
26
+ insertChain.values = vi.fn((v: unknown) => {
27
+ inserted.push(v);
28
+ return insertChain;
29
+ });
30
+ insertChain.then = (onF: any, onR: any) => Promise.resolve(undefined).then(onF, onR);
31
+ const db = {
32
+ select: vi.fn(() => selectChain),
33
+ insert: vi.fn(() => insertChain),
34
+ } as unknown as Database;
35
+ return { db, inserted };
36
+ }
37
+
38
+ const INPUT = {
39
+ workspaceId: 'razvan.radulescu/feature',
40
+ branch: 'razvan.radulescu/feature',
41
+ path: 'PR-Overviews/PR-7.html',
42
+ authorEmail: 'Alice@Example.com',
43
+ authorName: 'Alice',
44
+ };
45
+
46
+ describe('PendingCommitsService.enqueueIfAbsent', () => {
47
+ it('inserts (canonical workspace id, lowercased email) when no row exists', async () => {
48
+ const { db, inserted } = makeFakeDb(0);
49
+ const svc = new PendingCommitsService(db);
50
+
51
+ await expect(svc.enqueueIfAbsent(INPUT)).resolves.toBe(true);
52
+
53
+ expect(inserted).toHaveLength(1);
54
+ expect(inserted[0]).toMatchObject({
55
+ // Same canonicalization as `enqueue` — the worker claims per the
56
+ // ENCODED `knownWorkspaces()` id, so a decoded id would never drain.
57
+ workspaceId: 'razvan.radulescu%2Ffeature',
58
+ branch: 'razvan.radulescu/feature',
59
+ path: 'PR-Overviews/PR-7.html',
60
+ authorEmail: 'alice@example.com',
61
+ authorName: 'Alice',
62
+ });
63
+ });
64
+
65
+ it('is a no-op when ANY row exists for the tuple (pending, running, or needs_attention)', async () => {
66
+ const { db, inserted } = makeFakeDb(1);
67
+ const svc = new PendingCommitsService(db);
68
+
69
+ await expect(svc.enqueueIfAbsent(INPUT)).resolves.toBe(false);
70
+ expect(inserted).toHaveLength(0);
71
+ });
72
+ });
@@ -11,6 +11,7 @@ import { WorkflowEventBus } from '../event-bus.js';
11
11
  import { WorkflowService } from '../workflow.service.js';
12
12
  import type { Database } from '../../database/connection.js';
13
13
  import {
14
+ PullRebaseConflictError,
14
15
  PushNeedsAgentResolutionError,
15
16
  WorkflowValidationError,
16
17
  } from '../workflow.errors.js';
@@ -66,6 +67,7 @@ function makeFileLocks(holderUserId: string): FileLockService {
66
67
  function makePending(): PendingCommitsService {
67
68
  return {
68
69
  enqueue: vi.fn().mockResolvedValue(undefined),
70
+ enqueueIfAbsent: vi.fn().mockResolvedValue(true),
69
71
  claimNext: vi.fn().mockResolvedValue(null),
70
72
  markSucceeded: vi.fn().mockResolvedValue(undefined),
71
73
  markTransientFailure: vi.fn().mockResolvedValue(undefined),
@@ -82,6 +84,7 @@ function makeGit(overrides: Partial<{
82
84
  pushBehavior: 'ok' | 'nff' | 'auth-fail';
83
85
  pullBehavior: 'ok' | 'fail';
84
86
  pushAfterPullBehavior: 'ok' | 'fail';
87
+ hasUnpushedCommits: boolean;
85
88
  }> = {}): GitService {
86
89
  const commitFileResult = overrides.commitFile === undefined ? CHANGE : overrides.commitFile;
87
90
  const pushBehavior = overrides.pushBehavior ?? 'ok';
@@ -90,6 +93,7 @@ function makeGit(overrides: Partial<{
90
93
  let pushCalls = 0;
91
94
  return {
92
95
  commitFile: vi.fn().mockResolvedValue(commitFileResult),
96
+ hasUnpushedCommits: vi.fn().mockResolvedValue(overrides.hasUnpushedCommits ?? false),
93
97
  push: vi.fn().mockImplementation(async () => {
94
98
  pushCalls++;
95
99
  const phase = pushCalls === 1 ? pushBehavior : pushAfterPullBehavior;
@@ -265,10 +269,10 @@ describe('WorkflowService.runPendingCommit — worker entry point', () => {
265
269
  });
266
270
  });
267
271
 
268
- it('skips push + file-changed when commitFile reports nothing dirty (double-enqueue, identical bytes)', async () => {
272
+ it('skips push + file-changed on a no-op commit with nothing unpushed (double-enqueue, identical bytes)', async () => {
269
273
  // The worker treats a no-op commit as success and drops the row.
270
274
  // No push, no SSE event — there's no new sha for clients to see.
271
- const git = makeGit({ commitFile: null });
275
+ const git = makeGit({ commitFile: null, hasUnpushedCommits: false });
272
276
  const svc = makeFacade(git, makeFileLocks(USER.id), makePending(), events);
273
277
 
274
278
  await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER);
@@ -278,6 +282,41 @@ describe('WorkflowService.runPendingCommit — worker entry point', () => {
278
282
  expect(emitSpy).not.toHaveBeenCalled();
279
283
  });
280
284
 
285
+ it('STILL pushes on a no-op commit when the branch is ahead of origin (stranded autosave commit)', async () => {
286
+ // Regression: the autosave path commits locally with a best-effort
287
+ // push. When that push fails, the eventual queue row finds a CLEAN
288
+ // tree — and the old early-return treated it as success, dropping the
289
+ // row without ever pushing. The local commit stayed stranded forever
290
+ // and the recovery ladder never started.
291
+ const git = makeGit({ commitFile: null, hasUnpushedCommits: true });
292
+ const svc = makeFacade(git, makeFileLocks(USER.id), makePending(), events);
293
+
294
+ await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER);
295
+
296
+ expect(git.push).toHaveBeenCalledTimes(1);
297
+ // No new commit → still no file-changed (there's no new sha).
298
+ expect(emitSpy).not.toHaveBeenCalled();
299
+ });
300
+
301
+ it('no-op commit + unpushed + conflicting divergence throws so the worker ladder takes over', async () => {
302
+ // The stranded-commit case where origin ALSO moved with a conflicting
303
+ // change (the production stuck-workspace state): push rejects non-FF,
304
+ // the cooperative pull conflicts — the typed error must reach the
305
+ // worker so its retry → recovery-agent ladder runs.
306
+ const git = makeGit({
307
+ commitFile: null,
308
+ hasUnpushedCommits: true,
309
+ pushBehavior: 'nff',
310
+ pullBehavior: 'fail',
311
+ });
312
+ const svc = makeFacade(git, makeFileLocks(USER.id), makePending(), events);
313
+
314
+ await expect(svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER)).rejects.toBeInstanceOf(
315
+ PushNeedsAgentResolutionError,
316
+ );
317
+ expect(emitSpy).not.toHaveBeenCalled();
318
+ });
319
+
281
320
  it('recovers from non-fast-forward push via pull --rebase + retry, emits file-changed exactly once', async () => {
282
321
  // Teammate pushed to the same branch between our commit and our
283
322
  // push. The cooperative path pulls + retries — this is the case
@@ -338,3 +377,73 @@ describe('WorkflowService.runPendingCommit — worker entry point', () => {
338
377
  expect(emitSpy).not.toHaveBeenCalled();
339
378
  });
340
379
  });
380
+
381
+ describe('WorkflowService.updateFromRemote — pull-conflict recovery dispatch', () => {
382
+ const CONFLICT = new PullRebaseConflictError(
383
+ 'main',
384
+ ['PR-Overviews/PR-7.html', 'other.md'],
385
+ 'git rebase failed: could not apply 0d7b463',
386
+ );
387
+
388
+ function makeConflictingGit(err: unknown): GitService {
389
+ return {
390
+ pull: vi.fn().mockRejectedValue(err),
391
+ } as unknown as GitService;
392
+ }
393
+
394
+ it('queues ONE recovery row (first conflicted path, triggering user) and rethrows', async () => {
395
+ const pending = makePending();
396
+ const svc = makeFacade(
397
+ makeConflictingGit(CONFLICT), makeFileLocks(USER.id), pending, new WorkflowEventBus(),
398
+ );
399
+
400
+ await expect(svc.updateFromRemote('main', USER)).rejects.toBe(CONFLICT);
401
+
402
+ expect(pending.enqueueIfAbsent).toHaveBeenCalledTimes(1);
403
+ expect(pending.enqueueIfAbsent).toHaveBeenCalledWith({
404
+ workspaceId: 'main',
405
+ branch: 'main',
406
+ path: 'PR-Overviews/PR-7.html',
407
+ authorEmail: USER.email,
408
+ authorName: USER.name,
409
+ });
410
+ // `enqueue` (counter-resetting refresh) must NOT be used here — repeated
411
+ // sync attempts would starve the worker's ladder.
412
+ expect(pending.enqueue).not.toHaveBeenCalled();
413
+ });
414
+
415
+ it('falls back to the recovery-bot identity when no user is available', async () => {
416
+ const pending = makePending();
417
+ const svc = makeFacade(
418
+ makeConflictingGit(CONFLICT), makeFileLocks(USER.id), pending, new WorkflowEventBus(),
419
+ );
420
+
421
+ await expect(svc.updateFromRemote('main')).rejects.toBe(CONFLICT);
422
+
423
+ const input = (pending.enqueueIfAbsent as ReturnType<typeof vi.fn>).mock.calls[0][0];
424
+ expect(input.authorEmail).toContain('recovery-bot@');
425
+ });
426
+
427
+ it('does NOT queue recovery for a non-conflict pull failure (transient fetch error)', async () => {
428
+ const pending = makePending();
429
+ const transient = new Error('git fetch failed: could not resolve host');
430
+ const svc = makeFacade(
431
+ makeConflictingGit(transient), makeFileLocks(USER.id), pending, new WorkflowEventBus(),
432
+ );
433
+
434
+ await expect(svc.updateFromRemote('main', USER)).rejects.toBe(transient);
435
+ expect(pending.enqueueIfAbsent).not.toHaveBeenCalled();
436
+ });
437
+
438
+ it('a queue hiccup does not mask the conflict error', async () => {
439
+ const pending = makePending();
440
+ (pending.enqueueIfAbsent as ReturnType<typeof vi.fn>).mockRejectedValue(
441
+ new Error('db down'),
442
+ );
443
+ const svc = makeFacade(
444
+ makeConflictingGit(CONFLICT), makeFileLocks(USER.id), pending, new WorkflowEventBus(),
445
+ );
446
+
447
+ await expect(svc.updateFromRemote('main', USER)).rejects.toBe(CONFLICT);
448
+ });
449
+ });
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import { GitService } from '../git.service.js';
6
+ import { PullRebaseConflictError } from '../../workflow.errors.js';
6
7
  import { runGit, gitOut, stubWorkflowHooks, stubWorkspaceService } from './git-test-helpers.js';
7
8
 
8
9
  /** See git.service.createBranch.test.ts — same prod-mirroring layout. */
@@ -229,4 +230,77 @@ describe('GitService.pull', () => {
229
230
  const dash = await fs.readFile(path.join(repo, 'dash.html'), 'utf8');
230
231
  expect(dash).toBe('v1\n');
231
232
  });
233
+
234
+ // The production stuck-workspace state: a local commit and an origin commit
235
+ // both rewrote the same file, so replaying the local commit conflicts. The
236
+ // pull must (a) abort the rebase so the clone isn't left in a detached
237
+ // apply state, (b) surface a TYPED error naming the contested paths so the
238
+ // workflow layer can queue background recovery, and (c) leave the local
239
+ // commit intact (it's someone's saved content).
240
+ it('throws PullRebaseConflictError with the conflicted paths and aborts the rebase cleanly', async () => {
241
+ const { upstream, repo } = await seedWorkspace(root, workspaceId);
242
+
243
+ // Shared base version of the contested file.
244
+ await fs.writeFile(path.join(repo, 'PR-7.html'), 'base\n');
245
+ await runGit(repo, ['add', '.']);
246
+ await runGit(repo, ['commit', '-m', 'add overview']);
247
+ await runGit(repo, ['push', 'origin', 'target-company-state']);
248
+
249
+ // Origin rewrites it one way...
250
+ const pusher = path.join(root, '.conflict-pusher');
251
+ await runGit(root, ['clone', upstream, pusher]);
252
+ await fs.writeFile(path.join(pusher, 'PR-7.html'), 'origin version\n');
253
+ await runGit(pusher, ['add', '.']);
254
+ await runGit(pusher, ['commit', '-m', 'origin rewrite']);
255
+ await runGit(pusher, ['push', 'origin', 'target-company-state']);
256
+
257
+ // ...and the local branch rewrites it another way (the stranded commit).
258
+ await fs.writeFile(path.join(repo, 'PR-7.html'), 'local version\n');
259
+ await runGit(repo, ['add', '.']);
260
+ await runGit(repo, ['commit', '-m', 'local rewrite']);
261
+ const localHead = await gitOut(repo, ['rev-parse', 'HEAD']);
262
+
263
+ const svc = new GitService(
264
+ stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }),
265
+ stubWorkflowHooks(),
266
+ 'knowledge-base',
267
+ );
268
+
269
+ const err = await svc.pull(workspaceId).then(() => null).catch((e: unknown) => e);
270
+ expect(err).toBeInstanceOf(PullRebaseConflictError);
271
+ expect((err as PullRebaseConflictError).branch).toBe('target-company-state');
272
+ expect((err as PullRebaseConflictError).conflictedPaths).toEqual(['PR-7.html']);
273
+
274
+ // The rebase was aborted: no in-progress rebase state, clean tree, and
275
+ // the local commit is still HEAD with its version of the file on disk.
276
+ await expect(
277
+ fs.access(path.join(repo, '.git', 'rebase-merge')),
278
+ ).rejects.toMatchObject({ code: 'ENOENT' });
279
+ expect(await gitOut(repo, ['status', '--porcelain'])).toBe('');
280
+ expect(await gitOut(repo, ['rev-parse', 'HEAD'])).toBe(localHead);
281
+ // \r tolerated: Windows checkouts with autocrlf rewrite the file on the
282
+ // abort's checkout — content identity is what matters here.
283
+ const onDisk = await fs.readFile(path.join(repo, 'PR-7.html'), 'utf8');
284
+ expect(onDisk.replace(/\r/g, '')).toBe('local version\n');
285
+ });
286
+
287
+ // hasUnpushedCommits backs the worker's no-op arm (see runPendingCommit).
288
+ it('hasUnpushedCommits: false when in sync, true once a local commit lands', async () => {
289
+ const { repo } = await seedWorkspace(root, workspaceId);
290
+ const svc = new GitService(
291
+ stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }),
292
+ stubWorkflowHooks(),
293
+ 'knowledge-base',
294
+ );
295
+
296
+ await expect(svc.hasUnpushedCommits(workspaceId)).resolves.toBe(false);
297
+
298
+ await fs.writeFile(path.join(repo, 'new.txt'), 'x\n');
299
+ await runGit(repo, ['add', '.']);
300
+ await runGit(repo, ['commit', '-m', 'unpushed']);
301
+ await expect(svc.hasUnpushedCommits(workspaceId)).resolves.toBe(true);
302
+
303
+ await runGit(repo, ['push', 'origin', 'target-company-state']);
304
+ await expect(svc.hasUnpushedCommits(workspaceId)).resolves.toBe(false);
305
+ });
232
306
  });
@@ -29,6 +29,7 @@ import {
29
29
  BranchAuthorshipError,
30
30
  WorkflowValidationError,
31
31
  ProtectedBranchError,
32
+ PullRebaseConflictError,
32
33
  } from '../workflow.errors.js';
33
34
 
34
35
  const execFileAsync = promisify(execFile);
@@ -1387,16 +1388,65 @@ export class GitService implements IGitService {
1387
1388
  const remoteRef = await this.refreshRemoteBranchRef(cwd, branch);
1388
1389
  await this.git(cwd, ['rebase', '--autostash', remoteRef]);
1389
1390
  } catch (err) {
1391
+ // Capture the contested paths BEFORE the abort wipes the rebase
1392
+ // state — `--diff-filter=U` lists exactly the files whose replay
1393
+ // conflicted. Best-effort: an empty list just means this wasn't a
1394
+ // content conflict (or the probe itself failed) and the raw error
1395
+ // is surfaced unchanged.
1396
+ let conflictedPaths: string[] = [];
1397
+ try {
1398
+ const { stdout } = await this.git(cwd, ['diff', '--name-only', '--diff-filter=U']);
1399
+ conflictedPaths = stdout.split('\n').map((s) => s.trim()).filter(Boolean);
1400
+ } catch {
1401
+ // fall through with an empty list
1402
+ }
1390
1403
  // A failed rebase leaves the repo in a "REBASE_HEAD" / detached-apply state,
1391
1404
  // which makes every subsequent git command behave unexpectedly. Return the
1392
1405
  // working tree to a clean state before surfacing the original error.
1393
1406
  await this.git(cwd, ['rebase', '--abort']).catch(() => undefined);
1407
+ if (conflictedPaths.length > 0) {
1408
+ // Typed so the workflow layer can queue background recovery — this
1409
+ // divergence never resolves on its own (every retry pull hits the
1410
+ // same conflict) and the unpushed local commits are someone's saved
1411
+ // content stuck violating save=share.
1412
+ throw new PullRebaseConflictError(
1413
+ branch,
1414
+ conflictedPaths,
1415
+ err instanceof Error ? err.message : String(err),
1416
+ );
1417
+ }
1394
1418
  throw err;
1395
1419
  }
1396
1420
  this.accessControl?.invalidate(workspaceId);
1397
1421
  });
1398
1422
  }
1399
1423
 
1424
+ /**
1425
+ * Does the checked-out branch carry commits its remote-tracking ref
1426
+ * doesn't reach? Purely local (`rev-list` against
1427
+ * `refs/remotes/origin/<branch>` — no network), so a stale tracking ref
1428
+ * can only over-report: commits that were in fact already pushed make
1429
+ * this return true, and the follow-up push is a harmless no-op. A
1430
+ * missing tracking ref (branch never pushed) counts as unpushed — that
1431
+ * IS unshared work.
1432
+ *
1433
+ * Used by the pending-commits worker's no-op arm: a clean tree does NOT
1434
+ * mean "nothing to share" when a prior best-effort push (the autosave
1435
+ * path) failed and left the committed change stranded locally.
1436
+ */
1437
+ async hasUnpushedCommits(workspaceId: string): Promise<boolean> {
1438
+ const cwd = await this.repoDir(workspaceId);
1439
+ const branch = await this.currentBranch(cwd);
1440
+ try {
1441
+ const { stdout } = await this.git(cwd, [
1442
+ 'rev-list', '--count', `refs/remotes/origin/${branch}..HEAD`,
1443
+ ]);
1444
+ return Number(stdout.trim()) > 0;
1445
+ } catch {
1446
+ return true;
1447
+ }
1448
+ }
1449
+
1400
1450
  /**
1401
1451
  * Hard-reset the workspace's checked-out branch to `origin/<branch>`, fetching
1402
1452
  * first. This is the break-glass primitive behind roles.yaml recovery: it
@@ -216,6 +216,45 @@ export class PendingCommitsService {
216
216
  });
217
217
  }
218
218
 
219
+ /**
220
+ * Enqueue ONLY when no row — in ANY status — exists for
221
+ * `(workspaceId, branch, path)`. The pull-conflict recovery dispatch uses
222
+ * this instead of `enqueue` because that path can fire repeatedly (every
223
+ * branch focus / merge retries the pull) and `enqueue`'s refresh
224
+ * semantics would reset the existing row's retry counters each time —
225
+ * starving the worker's ladder so the recovery agent never spawns. A
226
+ * `needs_attention` row also blocks re-entry on purpose: the ladder
227
+ * already escalated that divergence to a human; spawning more agents on
228
+ * it would loop.
229
+ *
230
+ * Returns whether a row was inserted. Race window between the existence
231
+ * check and the insert is harmless — the schema allows duplicate rows and
232
+ * the worker's idempotent commit pass collapses them (same reasoning as
233
+ * `startupReconcile`'s bypass of the mutex).
234
+ */
235
+ async enqueueIfAbsent(input: EnqueueInput): Promise<boolean> {
236
+ const workspaceId = canonicalWorkspaceId(input.workspaceId);
237
+ const rows = await this.db
238
+ .select({ count: sql<number>`count(*)::int` })
239
+ .from(pendingCommits)
240
+ .where(
241
+ and(
242
+ eq(pendingCommits.workspaceId, workspaceId),
243
+ eq(pendingCommits.branch, input.branch),
244
+ eq(pendingCommits.path, input.path),
245
+ ),
246
+ );
247
+ if ((rows[0]?.count ?? 0) > 0) return false;
248
+ await this.db.insert(pendingCommits).values({
249
+ workspaceId,
250
+ branch: input.branch,
251
+ path: input.path,
252
+ authorEmail: input.authorEmail.trim().toLowerCase(),
253
+ authorName: input.authorName,
254
+ });
255
+ return true;
256
+ }
257
+
219
258
  /**
220
259
  * Atomically claim the next ready row for `workspaceId`. "Ready" means:
221
260
  *
@@ -120,6 +120,47 @@ export class PushNeedsAgentResolutionError extends WorkflowDomainError {
120
120
  }
121
121
  }
122
122
 
123
+ /**
124
+ * Refreshing a workspace from origin (`GitService.pull`) hit a rebase
125
+ * conflict: the workspace carries local commits origin doesn't have, origin
126
+ * moved ahead with changes that touch the same files, and replaying the
127
+ * local commits conflicts. The rebase has already been aborted — the
128
+ * workspace is back in its pre-pull state (diverged, clean tree).
129
+ *
130
+ * This state does NOT resolve itself: every subsequent pull hits the same
131
+ * conflict, and the local commits (someone's saved content, under
132
+ * save=share) stay unshared until the divergence is reconciled. The service
133
+ * layer therefore reacts by queueing a background recovery run (see
134
+ * `WorkflowService.updateFromRemote`); this error tells the caller what
135
+ * happened and which paths are contested.
136
+ *
137
+ * 409 like the other conflict errors — the operation needs the divergence
138
+ * cleared before it can succeed. `detail` (the raw git failure, already
139
+ * credential-redacted by `GitService.git`) is kept OFF the payload; it's for
140
+ * server logs and the recovery pipeline, not the client.
141
+ */
142
+ export class PullRebaseConflictError extends WorkflowDomainError {
143
+ readonly kind = 'pull-rebase-conflict' as const;
144
+ constructor(
145
+ readonly branch: string,
146
+ readonly conflictedPaths: string[],
147
+ readonly detail: string,
148
+ ) {
149
+ const list = conflictedPaths.join(', ');
150
+ super(
151
+ `Updating "${branch}" from origin hit conflicts in ${conflictedPaths.length} file(s): ${list}. ` +
152
+ `Automatic background recovery has been queued.`,
153
+ 409,
154
+ {
155
+ kind: 'pull-rebase-conflict',
156
+ branch,
157
+ conflictedPaths,
158
+ },
159
+ );
160
+ this.name = 'PullRebaseConflictError';
161
+ }
162
+ }
163
+
123
164
  /**
124
165
  * Generic 400 for workflow-input validation (malformed branch names, missing
125
166
  * fields, etc.). Carries an optional payload so callers can attach typed
@@ -192,9 +192,12 @@ export function createWorkflowRoutes(
192
192
  });
193
193
 
194
194
  router.post('/workspace/:id/workflow/update-from-remote', async (req, res) => {
195
- if (!(await requireUser(req, res))) return;
195
+ // The user is threaded through so a pull-conflict recovery row is
196
+ // attributed to whoever triggered the sync (see updateFromRemote).
197
+ const user = await requireUser(req, res);
198
+ if (!user) return;
196
199
  try {
197
- await workflow.updateFromRemote(req.params.id);
200
+ await workflow.updateFromRemote(req.params.id, user);
198
201
  res.json({ status: 'updated' });
199
202
  } catch (err) {
200
203
  const { status, body } = toHttpError(err);
@@ -60,9 +60,11 @@ import {
60
60
  ChangeRequestConflictsError,
61
61
  DuplicateChangeRequestError,
62
62
  RolesYamlPreservationError,
63
+ PullRebaseConflictError,
63
64
  PushNeedsAgentResolutionError,
64
65
  WorkflowValidationError,
65
66
  } from './workflow.errors.js';
67
+ import { RECOVERY_BOT_EMAIL, RECOVERY_BOT_NAME } from './recovery-bot.js';
66
68
  import { AccessDeniedError } from '../access/access-errors.js';
67
69
 
68
70
  const execFileAsync = promisify(execFile);
@@ -299,8 +301,64 @@ export class WorkflowService implements IWorkflowService {
299
301
  return this.git.fetch(workspaceId);
300
302
  }
301
303
 
302
- updateFromRemote(workspaceId: string): Promise<void> {
303
- return this.git.pull(workspaceId);
304
+ async updateFromRemote(workspaceId: string, user?: AuthUser): Promise<void> {
305
+ try {
306
+ await this.git.pull(workspaceId);
307
+ } catch (err) {
308
+ if (err instanceof PullRebaseConflictError) {
309
+ await this.queuePullConflictRecovery(workspaceId, err, user);
310
+ }
311
+ throw err;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * A pull hit a rebase conflict — the workspace carries local commits that
317
+ * conflict with origin, a state that never resolves on its own (every
318
+ * retry pull re-hits it, and the local commits are saved-but-unshared
319
+ * content violating save=share). Queue ONE pending-commits row for it so
320
+ * the worker's existing retry → recovery-agent → escalate ladder takes
321
+ * over in the background.
322
+ *
323
+ * `enqueueIfAbsent` (not `enqueue`): sync attempts can fire repeatedly
324
+ * (every branch focus / merge), and a fresh `enqueue` would reset the
325
+ * existing row's retry counters each time — starving the ladder so the
326
+ * recovery agent never spawns. It also refuses to resurrect a
327
+ * `needs_attention` row: once the ladder has escalated to a human, more
328
+ * agent runs on the same divergence are just a loop.
329
+ *
330
+ * One row, first conflicted path as the representative: the recovery
331
+ * agent's job is "bring the BRANCH into a pushable state", so one ladder
332
+ * per divergence — not one per conflicted file, which would multiply
333
+ * agent runs for a single underlying conflict.
334
+ *
335
+ * Best-effort by design — the caller is already surfacing the conflict
336
+ * error; a queue hiccup must not mask it.
337
+ */
338
+ private async queuePullConflictRecovery(
339
+ workspaceId: string,
340
+ err: PullRebaseConflictError,
341
+ user?: AuthUser,
342
+ ): Promise<void> {
343
+ try {
344
+ const queued = await this.pendingCommits.enqueueIfAbsent({
345
+ workspaceId,
346
+ branch: err.branch,
347
+ path: err.conflictedPaths[0],
348
+ authorEmail: user?.email ?? RECOVERY_BOT_EMAIL,
349
+ authorName: user?.name ?? RECOVERY_BOT_NAME,
350
+ });
351
+ if (queued) {
352
+ console.warn(
353
+ `[workflow] pull conflict on ws=${workspaceId} branch=${err.branch} (${err.conflictedPaths.join(', ')}) — queued background recovery`,
354
+ );
355
+ }
356
+ } catch (queueErr) {
357
+ console.error(
358
+ `[workflow] failed to queue pull-conflict recovery for ws=${workspaceId}:`,
359
+ queueErr instanceof Error ? queueErr.message : queueErr,
360
+ );
361
+ }
304
362
  }
305
363
 
306
364
  resetToRemote(workspaceId: string, branch: string): Promise<void> {
@@ -680,9 +738,21 @@ export class WorkflowService implements IWorkflowService {
680
738
  ): Promise<void> {
681
739
  const change = await this.git.commitFile(workspaceId, user, targetPath);
682
740
  if (!change) {
683
- // No-op commit (path was already clean — typical for a double-enqueue
684
- // or a save of bytes identical to HEAD). Nothing to push; nothing to
685
- // emit. The worker treats this as success and drops the row.
741
+ // No-op commit — the path was already clean. Usually a double-enqueue
742
+ // or a save of bytes identical to HEAD, BUT a clean tree does NOT
743
+ // prove there's nothing to share: the autosave path
744
+ // (`commitFileWhileLocked`) commits locally with a best-effort push,
745
+ // and when that push fails the commit stays stranded on the local
746
+ // branch with the tree clean. Treating that as success dropped the
747
+ // row without ever starting the retry → recovery-agent ladder —
748
+ // which is exactly how a diverged workspace ends up stuck forever.
749
+ // So: if the branch is ahead of its remote-tracking ref, push (with
750
+ // the same cooperative recovery, so a conflicting divergence throws
751
+ // and the worker's ladder takes over). Nothing to emit either way —
752
+ // there's no new sha.
753
+ if (await this.git.hasUnpushedCommits(workspaceId)) {
754
+ await this.pushWithRecovery(workspaceId, branch, targetPath, user);
755
+ }
686
756
  return;
687
757
  }
688
758
  await this.pushWithRecovery(workspaceId, branch, targetPath, user);
@@ -1352,6 +1422,14 @@ export class WorkflowService implements IWorkflowService {
1352
1422
  const targetWorkspace = await this.workspaceService.getOrCreateForBranch(baseBranch);
1353
1423
  await this.git.pull(targetWorkspace.id);
1354
1424
  } catch (err) {
1425
+ // Still best-effort for the merge response (the merge already landed
1426
+ // on origin) — but a rebase CONFLICT here means the target workspace
1427
+ // is stranded (local commits vs origin, never self-heals), so queue
1428
+ // the background recovery ladder before shrugging.
1429
+ if (err instanceof PullRebaseConflictError) {
1430
+ const targetWorkspaceId = encodeURIComponent(baseBranch);
1431
+ await this.queuePullConflictRecovery(targetWorkspaceId, err, user);
1432
+ }
1355
1433
  console.warn(
1356
1434
  `[merge] post-merge pull of target "${baseBranch}" failed — its workspace may be momentarily behind origin`,
1357
1435
  err,