@teamvibe/poller 0.7.1 → 0.7.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.
@@ -18,6 +18,14 @@ interface BrainConfig {
18
18
  * pushBrainChanges below.
19
19
  */
20
20
  export declare function cleanupStaleLocks(brainDir: string): Promise<void>;
21
+ /**
22
+ * Write the local (uncommitted) merge-driver attributes for a brain checkout.
23
+ * Idempotent and safe to call on every `getBrainPath` invocation, not just on
24
+ * first clone — this also self-heals brains that were checked out before
25
+ * this existed. Exported for testing — production call sites go through
26
+ * `getBrainPath`.
27
+ */
28
+ export declare function ensureGitAttributes(brainDir: string): Promise<void>;
21
29
  export declare function getBrainPath(brain?: BrainConfig, channelId?: string, workspaceId?: string): Promise<string>;
22
30
  /**
23
31
  * Update a brain repo (respects cooldown). Exported for testing — production
@@ -1,7 +1,7 @@
1
1
  import { exec } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import { existsSync, mkdirSync } from 'fs';
4
- import { readdir, stat, unlink } from 'fs/promises';
4
+ import { readdir, stat, unlink, mkdir, writeFile } from 'fs/promises';
5
5
  import { join, basename } from 'path';
6
6
  import { config } from './config.js';
7
7
  import { logger } from './logger.js';
@@ -128,6 +128,47 @@ async function reportBrainSync(brainId, workspaceId, event) {
128
128
  emitError('brain', `Brain sync report error: ${error instanceof Error ? error.message : String(error)}`, { severity: 'warn', context: { brainId } });
129
129
  }
130
130
  }
131
+ // tv#247: local-only (never committed, never synced) merge-driver config for
132
+ // a brain checkout. `merge=union` is one of git's built-in low-level merge
133
+ // drivers (alongside `text`/`binary`) — an attributes entry alone is enough,
134
+ // no `[merge "union"]` section in git config is needed (verified: a rebase
135
+ // across two branches with independent appends to a `merge=union`-attributed
136
+ // file merges both lines automatically instead of conflicting).
137
+ //
138
+ // Scoped ONLY to genuinely pure-append, line-independent files. Deliberately
139
+ // does NOT cover MEM_REGISTRY.md, MEM_REGISTRY_ARCHIVE.md, semantic/*, or
140
+ // episodic/* — consolidation edits those in place (promotes/removes
141
+ // entries), so a union merge there would resurrect already-promoted-and-
142
+ // removed content instead of resolving the conflict, rather than concatenate
143
+ // two genuinely independent additions.
144
+ //
145
+ // Written to `.git/info/attributes` (not a committed `.gitattributes`)
146
+ // because this is poller-local git behavior, not brain content — channel
147
+ // brain repos are written to only by their own sessions, never by the
148
+ // platform directly.
149
+ const GIT_ATTRIBUTES_CONTENT = ['memory/TODAY.md merge=union', 'memory/daily/*.md merge=union', ''].join('\n');
150
+ /**
151
+ * Write the local (uncommitted) merge-driver attributes for a brain checkout.
152
+ * Idempotent and safe to call on every `getBrainPath` invocation, not just on
153
+ * first clone — this also self-heals brains that were checked out before
154
+ * this existed. Exported for testing — production call sites go through
155
+ * `getBrainPath`.
156
+ */
157
+ export async function ensureGitAttributes(brainDir) {
158
+ try {
159
+ const gitDir = join(brainDir, '.git');
160
+ if (!existsSync(gitDir))
161
+ return; // not a git repo yet (defensive — getBrainPath always clones first)
162
+ const infoDir = join(gitDir, 'info');
163
+ await mkdir(infoDir, { recursive: true });
164
+ await writeFile(join(infoDir, 'attributes'), GIT_ATTRIBUTES_CONTENT);
165
+ }
166
+ catch (error) {
167
+ // Best-effort: worst case a future push-race on these files needs manual
168
+ // resolution same as before this existed. Not a reason to fail the caller.
169
+ logger.warn(`Brain ${brainDir}: failed to write .git/info/attributes: ${error instanceof Error ? error.message : error}`);
170
+ }
171
+ }
131
172
  export async function getBrainPath(brain, channelId, workspaceId) {
132
173
  if (!brain?.gitRepoUrl) {
133
174
  if (channelId) {
@@ -164,6 +205,9 @@ export async function getBrainPath(brain, channelId, workspaceId) {
164
205
  if (workspaceId)
165
206
  reportBrainSync(brain.brainId, workspaceId, 'pulled');
166
207
  }
208
+ // Idempotent, local-only — covers both the fresh-clone and existing-dir
209
+ // branches above, and self-heals brains cloned before this existed.
210
+ await ensureGitAttributes(brainDir);
167
211
  return brainDir;
168
212
  }
169
213
  /**
@@ -263,6 +307,52 @@ async function gitStashList(brainDir) {
263
307
  const { stdout } = await gitExec('git stash list', { cwd: brainDir });
264
308
  return stdout.trim();
265
309
  }
310
+ // Per-brainId rescue-ref name, seeded with a timestamp the first time a given
311
+ // brainId hits an unresolved push-race conflict, then reused (force-pushed
312
+ // to) on every later pushBrainChangesLocked invocation that still can't
313
+ // converge — e.g. the next message's push attempt while the conflict remains
314
+ // unresolved on disk. Without this, each retry would mint its own
315
+ // `conflict/<brainId>-<ts>` ref and spam origin with one throwaway branch per
316
+ // retry instead of one per actual incident. Cleared on the next successful
317
+ // push for that brainId, since any future conflict after that point is a new,
318
+ // unrelated incident that deserves its own ref.
319
+ const rescueRefNames = new Map();
320
+ /**
321
+ * Best-effort durability net for a `PUSH_RACE_CONFLICT`: push whatever HEAD
322
+ * currently points to onto a throwaway ref on origin before the caller
323
+ * reports the conflict, so the session's commits survive even if this
324
+ * checkout is lost (crash, re-clone, TTL'd container) before a human/AI does
325
+ * the manual resolution. Never throws — a failed rescue push just means no
326
+ * safety net for this occurrence, not a reason to mask the real
327
+ * PUSH_RACE_CONFLICT error the caller is about to report.
328
+ *
329
+ * Callers MUST ensure `HEAD` is the safe, non-mid-rebase content to rescue
330
+ * before calling this (e.g. after `git rebase --abort` restores the
331
+ * pre-rebase local branch) — this function pushes HEAD as-is.
332
+ */
333
+ async function pushRescueRef(brainDir, brainId) {
334
+ let refName = rescueRefNames.get(brainId);
335
+ if (!refName) {
336
+ // Unix seconds (not ms) — matches the <timestamp> format documented on
337
+ // the consuming side (poller-brain#443's MAINTENANCE.md `oldestAgeDays`
338
+ // derivation), so both sides agree on how to parse the ref name.
339
+ refName = `conflict/${brainId}-${Math.floor(Date.now() / 1000)}`;
340
+ rescueRefNames.set(brainId, refName);
341
+ }
342
+ try {
343
+ await gitExec(`git push --force origin HEAD:refs/heads/${refName}`, { cwd: brainDir });
344
+ logger.info(`Brain ${brainId} push-race: rescued local commits to ${refName}`);
345
+ return { refName, pushed: true };
346
+ }
347
+ catch (error) {
348
+ // Distinct sub-case from a successful rescue (same failure class as the
349
+ // known pb#440 issue — e.g. broken git credentials) — logged separately
350
+ // so it's never mistaken for "commits are safe on origin".
351
+ const message = error instanceof Error ? error.message : String(error);
352
+ logger.error(`Brain ${brainId} push-race: rescue-ref push to ${refName} itself FAILED, local commits remain only on disk: ${message}`);
353
+ return { refName, pushed: false };
354
+ }
355
+ }
266
356
  /**
267
357
  * Scan the commits about to be pushed for blobs over `maxBytes`. Returns the
268
358
  * offending paths/sizes, or an empty array if everything is under the limit.
@@ -382,6 +472,10 @@ async function pushBrainChangesLocked(brainDir, brainId, workspaceId, retryOptio
382
472
  attempt++;
383
473
  try {
384
474
  await gitExec(`git push -u origin ${branch}`, { cwd: brainDir });
475
+ // A successful push means any earlier unresolved conflict for this
476
+ // brainId (if any) is now resolved — a future conflict is a new,
477
+ // unrelated incident, so stop reusing that rescue ref name.
478
+ rescueRefNames.delete(brainId);
385
479
  if (attempt > 1) {
386
480
  logger.info(`Brain ${brainId} push-race auto-resolved after ${attempt} attempts`);
387
481
  emitError('brain', `Brain ${brainId} push-race auto-resolved after ${attempt} attempts`, {
@@ -427,7 +521,15 @@ async function pushBrainChangesLocked(brainDir, brainId, workspaceId, retryOptio
427
521
  const stashAfter = await gitStashList(brainDir);
428
522
  if (stashAfter !== stashBefore) {
429
523
  logger.error(`Brain ${brainId} push-race: autostash pop left changes stashed after rebase, treating as conflict`);
430
- emitError('brain', `Brain ${brainId} push-race autostash pop conflict — manual sync needed (stash: ${stashAfter.split('\n')[0]})`, { severity: 'error', context: { brainId, errorCode: 'PUSH_RACE_CONFLICT', attempts: attempt, reason: 'autostash_pop_conflict' } });
524
+ // The rebase itself (of committed local work onto origin) succeeded
525
+ // here — HEAD is the safe, non-mid-rebase content to rescue; only
526
+ // the separate stash-pop of uncommitted changes is what's stuck.
527
+ const { refName: rescueRef, pushed: rescuePushed } = await pushRescueRef(brainDir, brainId);
528
+ emitError('brain', `Brain ${brainId} push-race autostash pop conflict — manual sync needed (stash: ${stashAfter.split('\n')[0]})` +
529
+ (rescuePushed ? `, committed local work rescued to ${rescueRef}` : `, rescue push to ${rescueRef} also FAILED`), {
530
+ severity: 'error',
531
+ context: { brainId, errorCode: 'PUSH_RACE_CONFLICT', attempts: attempt, reason: 'autostash_pop_conflict', rescueRef, rescuePushed },
532
+ });
431
533
  return;
432
534
  }
433
535
  }
@@ -442,13 +544,21 @@ async function pushBrainChangesLocked(brainDir, brainId, workspaceId, retryOptio
442
544
  severity: 'error',
443
545
  context: { brainId, errorCode: 'PUSH_RACE_ABORT_FAILED', attempts: attempt },
444
546
  });
547
+ // Repo is left mid-rebase / in an uncertain state — do NOT push
548
+ // HEAD to a rescue ref here, that would rescue the wrong
549
+ // (partially-applied) content instead of the pre-conflict local work.
445
550
  return;
446
551
  }
552
+ // `git rebase --abort` above restored HEAD to the pre-rebase local
553
+ // branch — i.e. exactly the unpushed, pre-conflict work we need to
554
+ // keep durable, not whatever mid-rebase state existed a moment ago.
555
+ const { refName: rescueRef, pushed: rescuePushed } = await pushRescueRef(brainDir, brainId);
447
556
  const rebaseMessage = rebaseError instanceof Error ? rebaseError.message : String(rebaseError);
448
557
  logger.error(`Brain ${brainId} push-race rebase conflict: ${rebaseMessage}`);
449
- emitError('brain', `Brain ${brainId} push-race rebase conflict — manual sync needed: ${rebaseMessage}`, {
558
+ emitError('brain', `Brain ${brainId} push-race rebase conflict — manual sync needed: ${rebaseMessage}` +
559
+ (rescuePushed ? `, local commits rescued to ${rescueRef}` : `, rescue push to ${rescueRef} also FAILED`), {
450
560
  severity: 'error',
451
- context: { brainId, errorCode: 'PUSH_RACE_CONFLICT', attempts: attempt },
561
+ context: { brainId, errorCode: 'PUSH_RACE_CONFLICT', attempts: attempt, rescueRef, rescuePushed },
452
562
  });
453
563
  return;
454
564
  }
@@ -457,9 +567,25 @@ async function pushBrainChangesLocked(brainDir, brainId, workspaceId, retryOptio
457
567
  }
458
568
  const errorCode = NON_FF_REJECTION_RE.test(lastErrorMessage) ? 'PUSH_RACE_UNRESOLVED' : 'PUSH_FAILED';
459
569
  logger.warn(`Failed to push brain ${brainId} changes: ${lastErrorMessage}`);
460
- emitError('brain', `Failed to push brain ${brainId} changes: ${lastErrorMessage}`, {
570
+ // PUSH_RACE_UNRESOLVED means the retry budget ran out while HEAD was still
571
+ // in the safe, non-mid-rebase state left by the last successful rebase
572
+ // (the loop only reaches here via the `attempt >= maxAttempts` break right
573
+ // after a push rejection, never after a failed rebase — those return
574
+ // earlier). Same "local commits only on disk" risk as the two conflict
575
+ // cases above, just a different exhaustion cause — rescue it too.
576
+ // PUSH_FAILED (non-NON_FF failures, e.g. auth/network) deliberately
577
+ // skipped: a rescue push would almost always fail for the identical
578
+ // reason as the push it's rescuing.
579
+ let rescueRef;
580
+ let rescuePushed;
581
+ if (errorCode === 'PUSH_RACE_UNRESOLVED') {
582
+ ;
583
+ ({ refName: rescueRef, pushed: rescuePushed } = await pushRescueRef(brainDir, brainId));
584
+ }
585
+ emitError('brain', `Failed to push brain ${brainId} changes: ${lastErrorMessage}` +
586
+ (errorCode === 'PUSH_RACE_UNRESOLVED' ? (rescuePushed ? `, local commits rescued to ${rescueRef}` : `, rescue push to ${rescueRef} also FAILED`) : ''), {
461
587
  severity: errorCode === 'PUSH_RACE_UNRESOLVED' ? 'error' : 'warn',
462
- context: { brainId, errorCode, attempts: attempt },
588
+ context: { brainId, errorCode, attempts: attempt, ...(errorCode === 'PUSH_RACE_UNRESOLVED' ? { rescueRef, rescuePushed } : {}) },
463
589
  });
464
590
  }
465
591
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {