@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.
@@ -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 EXCLUDE_ENTRY = `${WORKTREES_SUBDIR}/\n`;
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(WORKTREES_SUBDIR + '/')) {
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
- await mkdir(join(brainPath, WORKTREES_SUBDIR), { recursive: true });
48
- await ensureExclude(brainPath);
49
- const worktreePath = join(brainPath, WORKTREES_SUBDIR, sessionId);
50
- // Defensive cleanup in case a previous attempt left an orphan
51
- try {
52
- await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: brainPath });
53
- }
54
- catch {
55
- // Not registered as a worktree — fine
56
- }
57
- await execFileAsync('git', ['fetch', 'origin', branch, '--quiet'], { cwd: brainPath });
58
- await execFileAsync('git', ['worktree', 'add', '--detach', worktreePath, `origin/${branch}`], { cwd: brainPath });
59
- return worktreePath;
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
- try {
69
- await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: brainPath });
70
- }
71
- catch {
72
- // Already gone, or never registered — fine
73
- }
74
- // Prune any stale administrative bookkeeping in .git/worktrees/
75
- try {
76
- await execFileAsync('git', ['worktree', 'prune'], { cwd: brainPath });
77
- }
78
- catch {
79
- // best-effort
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
- * worktree under `<brainPath>/.worktrees/` whose directory mtime is older
85
- * than `maxAgeMs`. Intended to run once at poller startup.
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
- const s = await stat(path);
105
- if (s.mtimeMs < cutoffMs) {
106
- await removeWorktree(brainPath, entry);
107
- removed.push(entry);
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
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.8.6",
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.6",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.8.6",
3
+ "version": "0.8.7",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "dist",
11
+ "bin",
11
12
  "npm-shrinkwrap.json"
12
13
  ],
13
14
  "scripts": {