@teamvibe/poller 0.8.6 → 0.8.7
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/bin/tv-git-credential.mjs +39 -0
- package/dist/auth-provider.js +8 -1
- package/dist/bg-task-registry.d.ts +12 -0
- package/dist/bg-task-registry.js +65 -0
- package/dist/brain-manager.d.ts +4 -0
- package/dist/brain-manager.js +87 -14
- package/dist/brains-bootstrap.js +6 -4
- package/dist/claude-spawner.d.ts +3 -0
- package/dist/claude-spawner.js +54 -21
- package/dist/connections-client.d.ts +66 -0
- package/dist/connections-client.js +128 -0
- package/dist/event-queue.js +11 -0
- package/dist/git-credential-env.d.ts +3 -0
- package/dist/git-credential-env.js +37 -0
- package/dist/git-credential-session.d.ts +33 -0
- package/dist/git-credential-session.js +265 -0
- package/dist/idle-restart.d.ts +1 -1
- package/dist/idle-restart.js +7 -62
- package/dist/inbox-manager.d.ts +4 -0
- package/dist/inbox-manager.js +29 -0
- package/dist/inbox-watcher.js +16 -3
- package/dist/maintenance-cron.d.ts +6 -0
- package/dist/poller.js +16 -7
- package/dist/pool-defer.d.ts +24 -1
- package/dist/pool-defer.js +101 -5
- package/dist/pool-telemetry.js +1 -0
- package/dist/redact-text.d.ts +3 -1
- package/dist/redact-text.js +17 -1
- package/dist/session-instance.d.ts +9 -0
- package/dist/session-instance.js +36 -0
- package/dist/subscription-runtime.js +2 -0
- package/dist/usage-probe-job.d.ts +2 -0
- package/dist/usage-probe-job.js +7 -1
- package/dist/worktree-manager.d.ts +7 -4
- package/dist/worktree-manager.js +94 -36
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
package/dist/worktree-manager.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'fs';
|
|
2
|
+
import { isGitCredentialInstanceRetained, isGitCredentialInstanceKnownDead, registeredWorktreeOwner } from './git-credential-session.js';
|
|
3
|
+
import { logger } from './logger.js';
|
|
4
|
+
import { createSessionInstance, readSessionOwner, writeSessionOwner, sameSessionInstance, reportSessionOwnerMismatch } from './session-instance.js';
|
|
2
5
|
import { mkdir, readdir, stat, appendFile, readFile } from 'fs/promises';
|
|
3
6
|
import { join } from 'path';
|
|
4
7
|
import { execFile } from 'child_process';
|
|
@@ -6,8 +9,24 @@ import { promisify } from 'util';
|
|
|
6
9
|
import { validateBrainId, resolveBrainDir } from './brain-path.js';
|
|
7
10
|
import { readInboxRuntime } from './inbox-manager.js';
|
|
8
11
|
const execFileAsync = promisify(execFile);
|
|
12
|
+
// Serialize creation and deletion at a reusable thread path: ownership must
|
|
13
|
+
// stay stable between the marker check and completion of git worktree remove.
|
|
14
|
+
const worktreeOperations = new Map();
|
|
15
|
+
async function withWorktreeLock(path, operation) {
|
|
16
|
+
const previous = worktreeOperations.get(path) ?? Promise.resolve();
|
|
17
|
+
const current = previous.catch(() => { }).then(operation);
|
|
18
|
+
worktreeOperations.set(path, current);
|
|
19
|
+
try {
|
|
20
|
+
return await current;
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
if (worktreeOperations.get(path) === current)
|
|
24
|
+
worktreeOperations.delete(path);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
9
27
|
const WORKTREES_SUBDIR = '.worktrees';
|
|
10
|
-
const
|
|
28
|
+
const OWNER_FILE = '.teamvibe-session-owner.json';
|
|
29
|
+
const EXCLUDE_ENTRY = `${WORKTREES_SUBDIR}/\n${OWNER_FILE}\n`;
|
|
11
30
|
/**
|
|
12
31
|
* Spawned (non-foreground) Claude sessions run in isolated git worktrees off
|
|
13
32
|
* the brain repo to avoid the sync-watchdog race in teamvibe.ai#120.
|
|
@@ -32,7 +51,7 @@ async function ensureExclude(brainPath) {
|
|
|
32
51
|
catch {
|
|
33
52
|
// .git/info/exclude may not exist (rare); fall back to creating it
|
|
34
53
|
}
|
|
35
|
-
if (!current.split('\n').includes(
|
|
54
|
+
if (!current.split('\n').includes(OWNER_FILE)) {
|
|
36
55
|
await appendFile(excludePath, EXCLUDE_ENTRY);
|
|
37
56
|
}
|
|
38
57
|
}
|
|
@@ -43,46 +62,76 @@ async function ensureExclude(brainPath) {
|
|
|
43
62
|
* Idempotent against existing worktrees at the same path: if one is found,
|
|
44
63
|
* it's removed and recreated to guarantee a clean start.
|
|
45
64
|
*/
|
|
46
|
-
export async function createWorktree(brainPath, sessionId, branch = 'main') {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
export async function createWorktree(brainPath, sessionId, branch = 'main', instance = createSessionInstance()) {
|
|
66
|
+
return withWorktreeLock(join(brainPath, WORKTREES_SUBDIR, sessionId), async () => {
|
|
67
|
+
await mkdir(join(brainPath, WORKTREES_SUBDIR), { recursive: true });
|
|
68
|
+
await ensureExclude(brainPath);
|
|
69
|
+
const worktreePath = join(brainPath, WORKTREES_SUBDIR, sessionId);
|
|
70
|
+
// Defensive cleanup in case a previous attempt left an orphan
|
|
71
|
+
try {
|
|
72
|
+
await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: brainPath });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Not registered as a worktree — fine
|
|
76
|
+
}
|
|
77
|
+
await execFileAsync('git', ['fetch', 'origin', branch, '--quiet'], { cwd: brainPath });
|
|
78
|
+
await execFileAsync('git', ['worktree', 'add', '--no-checkout', '--detach', worktreePath, `origin/${branch}`], { cwd: brainPath });
|
|
79
|
+
// Register without checkout first: no usable checkout exists until its
|
|
80
|
+
// atomic 0600 ownership marker is installed. A crash before that leaves
|
|
81
|
+
// an incomplete, age-gated orphan rather than an unmarked live worktree.
|
|
82
|
+
writeSessionOwner(join(worktreePath, OWNER_FILE), instance);
|
|
83
|
+
await execFileAsync('git', ['reset', '--hard', `origin/${branch}`], { cwd: worktreePath });
|
|
84
|
+
return worktreePath;
|
|
85
|
+
});
|
|
60
86
|
}
|
|
61
87
|
/**
|
|
62
88
|
* Remove a worktree by session ID. Force-removes so dirty state from a crashed
|
|
63
89
|
* session doesn't block cleanup — the contract is that any session work the
|
|
64
90
|
* caller wanted to preserve has already been pushed to `origin/<branch>`.
|
|
65
91
|
*/
|
|
66
|
-
export async function removeWorktree(brainPath, sessionId) {
|
|
92
|
+
export async function removeWorktree(brainPath, sessionId, instance) {
|
|
93
|
+
return removeOwnedWorktree(brainPath, sessionId, instance);
|
|
94
|
+
}
|
|
95
|
+
async function removeOwnedWorktree(brainPath, sessionId, instance, orphanCutoff) {
|
|
67
96
|
const worktreePath = join(brainPath, WORKTREES_SUBDIR, sessionId);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
97
|
+
const expected = instance === undefined ? readSessionOwner(join(worktreePath, OWNER_FILE)) : instance;
|
|
98
|
+
return withWorktreeLock(worktreePath, async () => {
|
|
99
|
+
const current = readSessionOwner(join(worktreePath, OWNER_FILE));
|
|
100
|
+
if (orphanCutoff !== undefined) {
|
|
101
|
+
// Recheck under the same lock as creation, including retention before age.
|
|
102
|
+
if (isGitCredentialInstanceRetained(current, undefined, sessionId))
|
|
103
|
+
return;
|
|
104
|
+
if (!(current && isGitCredentialInstanceKnownDead(current)) && (await stat(worktreePath)).mtimeMs >= orphanCutoff)
|
|
105
|
+
return;
|
|
106
|
+
if (expected ? !sameSessionInstance(current, expected) : current !== undefined)
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
else if (!expected || !sameSessionInstance(current, expected)) {
|
|
110
|
+
if (expected)
|
|
111
|
+
reportSessionOwnerMismatch(expected, current);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: brainPath });
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Already gone, or never registered — fine
|
|
119
|
+
}
|
|
120
|
+
// Prune any stale administrative bookkeeping in .git/worktrees/
|
|
121
|
+
try {
|
|
122
|
+
await execFileAsync('git', ['worktree', 'prune'], { cwd: brainPath });
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// best-effort
|
|
126
|
+
}
|
|
127
|
+
});
|
|
81
128
|
}
|
|
82
129
|
/**
|
|
83
130
|
* Sweep orphan worktrees left behind by crashed sessions. Removes every
|
|
84
|
-
*
|
|
85
|
-
* than `maxAgeMs`.
|
|
131
|
+
* known-dead instance immediately, or an unregistered/legacy orphan older
|
|
132
|
+
* than `maxAgeMs`. Live sessions and registered tasks always retain their
|
|
133
|
+
* worktrees, including those with missing, malformed or changed markers.
|
|
134
|
+
* Intended to run once at poller startup.
|
|
86
135
|
*/
|
|
87
136
|
export async function cleanupOrphanWorktrees(brainPath, maxAgeMs = 24 * 60 * 60 * 1000) {
|
|
88
137
|
const worktreesDir = join(brainPath, WORKTREES_SUBDIR);
|
|
@@ -97,15 +146,24 @@ export async function cleanupOrphanWorktrees(brainPath, maxAgeMs = 24 * 60 * 60
|
|
|
97
146
|
const cutoffMs = Date.now() - maxAgeMs;
|
|
98
147
|
for (const entry of entries) {
|
|
99
148
|
const path = join(worktreesDir, entry);
|
|
149
|
+
const instance = readSessionOwner(join(path, OWNER_FILE));
|
|
100
150
|
const pending = readInboxRuntime(brainPath, entry);
|
|
101
151
|
if (pending?.pin.resumePending && pending.pin.effectiveCwd === path)
|
|
102
152
|
continue;
|
|
103
153
|
try {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
154
|
+
// Retain by thread metadata as well as marker: a corrupt marker cannot
|
|
155
|
+
// hide a live session or registered task from the shared predicate.
|
|
156
|
+
if (isGitCredentialInstanceRetained(instance, undefined, entry)) {
|
|
157
|
+
// Report damaged ownership with both IDs without reading credentials.
|
|
158
|
+
const expected = registeredWorktreeOwner(entry);
|
|
159
|
+
if (expected && !sameSessionInstance(instance, expected))
|
|
160
|
+
reportSessionOwnerMismatch(expected, instance);
|
|
161
|
+
logger.info(`[Worktree] Retained ${path}: live session or registered task`);
|
|
162
|
+
continue;
|
|
108
163
|
}
|
|
164
|
+
await removeOwnedWorktree(brainPath, entry, instance ?? null, cutoffMs);
|
|
165
|
+
if (!existsSync(path))
|
|
166
|
+
removed.push(entry);
|
|
109
167
|
}
|
|
110
168
|
catch {
|
|
111
169
|
// skip entries we can't stat
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teamvibe/poller",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.7",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@teamvibe/poller",
|
|
9
|
-
"version": "0.8.
|
|
9
|
+
"version": "0.8.7",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@aws-sdk/client-dynamodb": "3.1108.0",
|
|
12
12
|
"@aws-sdk/client-sqs": "3.1108.0",
|
package/package.json
CHANGED