@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.
- package/dist/brain-manager.d.ts +8 -0
- package/dist/brain-manager.js +132 -6
- package/package.json +1 -1
package/dist/brain-manager.d.ts
CHANGED
|
@@ -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
|
package/dist/brain-manager.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|