@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
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Defence in depth for direct invocations that bypass Git's env -u launcher.
|
|
3
|
+
for (const key of ['GITHUB_TOKEN', 'GH_TOKEN', 'GITLAB_TOKEN']) delete process.env[key]
|
|
4
|
+
|
|
5
|
+
import { appendFileSync, readFileSync } from 'node:fs'
|
|
6
|
+
import { dirname, join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
// Git calls this executable with only get/store/erase in argv. Read each get
|
|
9
|
+
// afresh; never cache credentials or ask for input beyond Git's request.
|
|
10
|
+
if (process.argv[2] === 'get') {
|
|
11
|
+
try {
|
|
12
|
+
const request = {}
|
|
13
|
+
for (const line of readFileSync(0, 'utf8').split('\n')) {
|
|
14
|
+
if (!line) break
|
|
15
|
+
const separator = line.indexOf('=')
|
|
16
|
+
if (separator > 0) request[line.slice(0, separator)] = line.slice(separator + 1)
|
|
17
|
+
}
|
|
18
|
+
const file = process.env.TV_GIT_TOKEN_FILE
|
|
19
|
+
const credential = JSON.parse(readFileSync(file, 'utf8'))
|
|
20
|
+
if (request.protocol !== 'https' || request.host !== credential.host ||
|
|
21
|
+
credential.host !== 'github.com' || typeof credential.token !== 'string' ||
|
|
22
|
+
!credential.token || /[\r\n\0]/.test(credential.token) ||
|
|
23
|
+
!['github_token', 'github_app'].includes(credential.kind)) process.exit(0)
|
|
24
|
+
if (credential.expiresAt !== undefined) {
|
|
25
|
+
const expiry = Date.parse(credential.expiresAt)
|
|
26
|
+
if (!Number.isFinite(expiry) || expiry <= Date.now()) {
|
|
27
|
+
const diagnostic = `git_credential_expired kind=${credential.kind}\n`
|
|
28
|
+
process.stderr.write(diagnostic)
|
|
29
|
+
// Value-free spool lets the poller report detached-task expiry through
|
|
30
|
+
// its usual error queue even after the launching agent has exited.
|
|
31
|
+
try { appendFileSync(join(dirname(file), 'git-credential-events'), diagnostic, { mode: 0o600 }) } catch {}
|
|
32
|
+
process.exit(0)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
process.stdout.write(`username=x-access-token\npassword=${credential.token}\n`)
|
|
36
|
+
} catch {
|
|
37
|
+
// No output and success means Git proceeds to its prompt-disabled failure.
|
|
38
|
+
}
|
|
39
|
+
}
|
package/dist/auth-provider.js
CHANGED
|
@@ -2,6 +2,7 @@ import { config, POLLER_VERSION } from './config.js';
|
|
|
2
2
|
import { logger } from './logger.js';
|
|
3
3
|
import { emitError } from './event-queue.js';
|
|
4
4
|
import { getBaseBrainCommitSha } from './base-brain-metadata.js';
|
|
5
|
+
import { connectionsClient, refreshConnectionTuples } from './connections-client.js';
|
|
5
6
|
export class AuthHttpError extends Error {
|
|
6
7
|
status;
|
|
7
8
|
constructor(status, _body) {
|
|
@@ -24,6 +25,12 @@ const REFRESH_INTERVAL_MS = 50 * 60 * 1000; // 50 minutes (before 1hr expiry)
|
|
|
24
25
|
const HEARTBEAT_INTERVAL_MS = 60 * 1000; // 1 minute
|
|
25
26
|
async function sendHeartbeat(apiUrl, token, version) {
|
|
26
27
|
try {
|
|
28
|
+
if (currentPollerId) {
|
|
29
|
+
try {
|
|
30
|
+
await refreshConnectionTuples(currentPollerId);
|
|
31
|
+
}
|
|
32
|
+
catch { /* Heartbeat must still report liveness if tuple refresh fails. */ }
|
|
33
|
+
}
|
|
27
34
|
const baseBrainSha = await getBaseBrainCommitSha(config.BASE_BRAIN_PATH);
|
|
28
35
|
if (!baseBrainSha)
|
|
29
36
|
logger.warn('[Heartbeat] Could not read base-brain HEAD for pin readback');
|
|
@@ -33,7 +40,7 @@ async function sendHeartbeat(apiUrl, token, version) {
|
|
|
33
40
|
Authorization: `Bearer ${token}`,
|
|
34
41
|
'Content-Type': 'application/json',
|
|
35
42
|
},
|
|
36
|
-
body: JSON.stringify({ version, baseBrainSha, baseBrainBranch: config.BASE_BRAIN_BRANCH }),
|
|
43
|
+
body: JSON.stringify({ version, baseBrainSha, baseBrainBranch: config.BASE_BRAIN_BRANCH, legacyGitBrains: connectionsClient.legacyGitBrains }),
|
|
37
44
|
});
|
|
38
45
|
if (!response.ok) {
|
|
39
46
|
logger.warn(`Heartbeat failed (${response.status})`);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface BgTaskStatus {
|
|
2
|
+
state?: string;
|
|
3
|
+
runner_crashed?: string;
|
|
4
|
+
pid?: string;
|
|
5
|
+
started?: string;
|
|
6
|
+
ttl?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function parseStatus(text: string): BgTaskStatus;
|
|
9
|
+
export declare function pidAlive(pid: number): boolean;
|
|
10
|
+
export declare function lifecycleOf(kv: BgTaskStatus, now: number): string;
|
|
11
|
+
export declare function bgTaskRoot(): string;
|
|
12
|
+
export declare function listDirs(path: string): string[];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { config } from './config.js';
|
|
4
|
+
// Shared with idle restart and credential lifetime cleanup. Status belongs to
|
|
5
|
+
// poller-brain's background-task skill; no second task-liveness registry.
|
|
6
|
+
const ABANDON_GRACE_SEC = 60; // mirrors bg-task.mjs's ABANDON_GRACE_SEC
|
|
7
|
+
export function parseStatus(text) {
|
|
8
|
+
const kv = {};
|
|
9
|
+
for (const line of text.split('\n')) {
|
|
10
|
+
const i = line.indexOf('=');
|
|
11
|
+
if (i > 0)
|
|
12
|
+
kv[line.slice(0, i)] = line.slice(i + 1);
|
|
13
|
+
}
|
|
14
|
+
return kv;
|
|
15
|
+
}
|
|
16
|
+
export function pidAlive(pid) {
|
|
17
|
+
try {
|
|
18
|
+
process.kill(pid, 0);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Same four-way classification as bg-task.mjs's lifecycleOf, minus the tests-only
|
|
26
|
+
// `alive` injection point (this module has its own dedicated tests instead).
|
|
27
|
+
export function lifecycleOf(kv, now) {
|
|
28
|
+
// Trust the runner's own claim, whatever it says (bg-task.mjs:139) — not just the two
|
|
29
|
+
// values the runner writes today (`finished`/`timed-out`, bg-task-runner.mjs:403).
|
|
30
|
+
// Mirroring the check itself, not just its current output, means the two
|
|
31
|
+
// implementations of this format can't quietly diverge if the runner ever starts
|
|
32
|
+
// writing a state value this list doesn't happen to enumerate — hardcoding a closed
|
|
33
|
+
// set here would silently fall through to the pid/TTL heuristics below instead.
|
|
34
|
+
if (kv.state)
|
|
35
|
+
return kv.state;
|
|
36
|
+
if (kv.runner_crashed)
|
|
37
|
+
return 'crashed';
|
|
38
|
+
if (Object.keys(kv).length === 0)
|
|
39
|
+
return 'unknown';
|
|
40
|
+
const pid = Number(kv.pid);
|
|
41
|
+
if (!Number.isInteger(pid) || pid <= 0 || !pidAlive(pid))
|
|
42
|
+
return 'abandoned';
|
|
43
|
+
const started = kv.started ? Date.parse(kv.started) : NaN;
|
|
44
|
+
const ttl = Number(String(kv.ttl ?? '').replace(/s$/, ''));
|
|
45
|
+
if (Number.isFinite(started) && Number.isFinite(ttl) && now - started > (ttl + ABANDON_GRACE_SEC) * 1000) {
|
|
46
|
+
return 'abandoned';
|
|
47
|
+
}
|
|
48
|
+
return 'running';
|
|
49
|
+
}
|
|
50
|
+
export function bgTaskRoot() {
|
|
51
|
+
// Mirrors bg-task.mjs's taskRoot() default exactly, minus the per-channel
|
|
52
|
+
// namespace — the poller checks liveness across every channel it serves, not just
|
|
53
|
+
// one, so it walks the parent directory instead of joining a single channel ID.
|
|
54
|
+
return process.env['BG_TASK_ROOT'] || join(config.PERSISTENT_STORAGE_PATH || '/tmp', 'bg-tasks');
|
|
55
|
+
}
|
|
56
|
+
export function listDirs(path) {
|
|
57
|
+
try {
|
|
58
|
+
return readdirSync(path, { withFileTypes: true })
|
|
59
|
+
.filter((entry) => entry.isDirectory())
|
|
60
|
+
.map((entry) => entry.name);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
package/dist/brain-manager.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
declare const execAsync: typeof exec.__promisify__;
|
|
3
|
+
/** @internal Inject a git process for clone/push contract tests. */
|
|
4
|
+
export declare function __setGitExecForTest(runner?: typeof execAsync): void;
|
|
1
5
|
export declare function withBrainLock<T>(key: string, fn: () => Promise<T>): Promise<T>;
|
|
2
6
|
interface BrainConfig {
|
|
3
7
|
brainId: string;
|
package/dist/brain-manager.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createGitCredentialSession, finishGitCredentialSession } from './git-credential-session.js';
|
|
1
2
|
import { exec } from 'child_process';
|
|
2
3
|
import { promisify } from 'util';
|
|
3
4
|
import { existsSync, mkdirSync } from 'fs';
|
|
@@ -8,7 +9,14 @@ import { logger } from './logger.js';
|
|
|
8
9
|
import { emitError } from './event-queue.js';
|
|
9
10
|
import { brainLockKey, resolveBrainDir, fallbackBrainPath, legacyBrainPath } from './brain-path.js';
|
|
10
11
|
import { startupFailureDetail } from './startup-auth.js';
|
|
12
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
13
|
+
import { connectionsClient } from './connections-client.js';
|
|
14
|
+
import { buildGitCredentialEnv } from './git-credential-env.js';
|
|
15
|
+
import { redactGitDiagnostic, redactRepoUrl } from './redact-text.js';
|
|
11
16
|
const execAsync = promisify(exec);
|
|
17
|
+
let gitRunner = execAsync;
|
|
18
|
+
/** @internal Inject a git process for clone/push contract tests. */
|
|
19
|
+
export function __setGitExecForTest(runner) { gitRunner = runner ?? execAsync; }
|
|
12
20
|
// Clean env for git commands:
|
|
13
21
|
// - npm/npx sets GIT_ASKPASS which prevents credential helpers from working
|
|
14
22
|
// - PERSISTENT_STORAGE_PATH/bin may contain tools like gh not in default PATH
|
|
@@ -20,8 +28,68 @@ if (config.PERSISTENT_STORAGE_PATH) {
|
|
|
20
28
|
gitEnv['PATH'] = `${binDir}:${gitEnv['PATH'] || ''}`;
|
|
21
29
|
}
|
|
22
30
|
}
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
const gitCredentialContext = new AsyncLocalStorage();
|
|
32
|
+
const githubBrains = new Set();
|
|
33
|
+
async function gitExec(command, options) {
|
|
34
|
+
const context = gitCredentialContext.getStore();
|
|
35
|
+
const run = async (credential) => {
|
|
36
|
+
const session = credential?.source === 'platform'
|
|
37
|
+
? createGitCredentialSession(credential, { channelId: '', threadId: `brain:${context?.brainId ?? ''}` }) : undefined;
|
|
38
|
+
try {
|
|
39
|
+
return await gitRunner(command, { ...options, env: credential ? buildGitCredentialEnv(gitEnv, credential, session?.tokenFile) : gitEnv });
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
if (session)
|
|
43
|
+
finishGitCredentialSession(session);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const recorded = () => {
|
|
47
|
+
if (context && /^git (?:clone|fetch|pull|push|submodule)\b/.test(command))
|
|
48
|
+
connectionsClient.recordBrainSource(context.brainId, context.credential.source === 'platform' ? 'platform' : 'legacy');
|
|
49
|
+
};
|
|
50
|
+
const safeResult = (result) => ({ stdout: redactGitDiagnostic(result.stdout), stderr: redactGitDiagnostic(result.stderr) });
|
|
51
|
+
const safeError = (error) => {
|
|
52
|
+
if (error instanceof Error) {
|
|
53
|
+
error.message = redactGitDiagnostic(error.message);
|
|
54
|
+
const detail = error;
|
|
55
|
+
if (detail.stdout)
|
|
56
|
+
detail.stdout = redactGitDiagnostic(detail.stdout);
|
|
57
|
+
if (detail.stderr)
|
|
58
|
+
detail.stderr = redactGitDiagnostic(detail.stderr);
|
|
59
|
+
return error;
|
|
60
|
+
}
|
|
61
|
+
return new Error(redactGitDiagnostic(String(error)));
|
|
62
|
+
};
|
|
63
|
+
try {
|
|
64
|
+
const result = await run(context?.credential);
|
|
65
|
+
recorded();
|
|
66
|
+
return safeResult(result);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
70
|
+
if (!context || context.credential.source !== 'platform' || !/(?:HTTP|returned error:)\s*(?:401|403)|Authentication failed/i.test(message))
|
|
71
|
+
throw safeError(error);
|
|
72
|
+
connectionsClient.invalidate('brain', context.brainId);
|
|
73
|
+
const refreshed = await connectionsClient.get('brain', context.brainId, true);
|
|
74
|
+
if (refreshed.source !== 'platform')
|
|
75
|
+
throw safeError(error);
|
|
76
|
+
context.credential = refreshed;
|
|
77
|
+
let result;
|
|
78
|
+
try {
|
|
79
|
+
result = await run(refreshed);
|
|
80
|
+
}
|
|
81
|
+
catch (retryError) {
|
|
82
|
+
throw safeError(retryError);
|
|
83
|
+
} // exactly one remote-auth retry
|
|
84
|
+
recorded();
|
|
85
|
+
return safeResult(result);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function withGitCredential(brainId, workspaceId, fn) {
|
|
89
|
+
if (!workspaceId || !githubBrains.has(brainId) || !config.TEAMVIBE_API_URL || !config.TEAMVIBE_POLLER_TOKEN)
|
|
90
|
+
return fn();
|
|
91
|
+
const credential = await connectionsClient.get('brain', brainId);
|
|
92
|
+
return gitCredentialContext.run({ brainId, credential }, fn);
|
|
25
93
|
}
|
|
26
94
|
// Node's child_process.exec() defaults to a 1MB stdout cap. This bounds the
|
|
27
95
|
// text output of a git plumbing command (e.g. one line per object listed),
|
|
@@ -119,7 +187,7 @@ async function reportBrainSync(brainId, workspaceId, event) {
|
|
|
119
187
|
Authorization: `Bearer ${config.TEAMVIBE_POLLER_TOKEN}`,
|
|
120
188
|
'Content-Type': 'application/json',
|
|
121
189
|
},
|
|
122
|
-
body: JSON.stringify({ brainId, workspaceId, event }),
|
|
190
|
+
body: JSON.stringify({ brainId: redactGitDiagnostic(brainId), workspaceId: redactGitDiagnostic(workspaceId), event, source: gitCredentialContext.getStore()?.credential.source === 'platform' ? 'platform' : 'legacy' }),
|
|
123
191
|
});
|
|
124
192
|
if (!response.ok) {
|
|
125
193
|
logger.warn(`Brain sync report failed (${response.status})`);
|
|
@@ -127,8 +195,9 @@ async function reportBrainSync(brainId, workspaceId, event) {
|
|
|
127
195
|
}
|
|
128
196
|
}
|
|
129
197
|
catch (error) {
|
|
130
|
-
|
|
131
|
-
|
|
198
|
+
const diagnostic = redactGitDiagnostic(error instanceof Error ? error.message : String(error));
|
|
199
|
+
logger.warn(`Brain sync report error: ${diagnostic}`);
|
|
200
|
+
emitError('brain', `Brain sync report error: ${diagnostic}`, { severity: 'warn', context: { brainId: redactGitDiagnostic(brainId) } });
|
|
132
201
|
}
|
|
133
202
|
}
|
|
134
203
|
// tv#247: local-only (never committed, never synced) merge-driver config for
|
|
@@ -184,7 +253,9 @@ export async function getBrainPath(brain, channelId, workspaceId) {
|
|
|
184
253
|
return config.DEFAULT_BRAIN_PATH;
|
|
185
254
|
}
|
|
186
255
|
const brainDir = resolveBrainDir(workspaceId, brain.brainId);
|
|
187
|
-
|
|
256
|
+
if (/^https:\/\/github\.com\//i.test(brain.gitRepoUrl))
|
|
257
|
+
githubBrains.add(brain.brainId);
|
|
258
|
+
return withBrainLock(brainLockKey(brainDir, brain.brainId, workspaceId), () => withGitCredential(brain.brainId, workspaceId, () => getWorkspaceBrainPath(brain, workspaceId)));
|
|
188
259
|
}
|
|
189
260
|
async function getWorkspaceBrainPath(brain, workspaceId) {
|
|
190
261
|
const brainDir = resolveBrainDir(workspaceId, brain.brainId);
|
|
@@ -195,7 +266,7 @@ async function getWorkspaceBrainPath(brain, workspaceId) {
|
|
|
195
266
|
}
|
|
196
267
|
mkdirSync(dirname(brainDir), { recursive: true });
|
|
197
268
|
if (!existsSync(brainDir)) {
|
|
198
|
-
logger.info(`Cloning brain ${brain.brainId} from ${brain.gitRepoUrl} (branch: ${brain.branch})...`);
|
|
269
|
+
logger.info(`Cloning brain ${brain.brainId} from ${redactRepoUrl(brain.gitRepoUrl)} (branch: ${brain.branch})...`);
|
|
199
270
|
await gitExec(`git clone --recurse-submodules ${brain.gitRepoUrl} ${brainDir}`);
|
|
200
271
|
// Ensure the desired branch exists (handles empty repos and missing branches)
|
|
201
272
|
const { stdout: currentBranch } = await gitExec('git branch --show-current', { cwd: brainDir });
|
|
@@ -214,8 +285,8 @@ async function getWorkspaceBrainPath(brain, workspaceId) {
|
|
|
214
285
|
reportBrainSync(brain.brainId, workspaceId, 'pulled');
|
|
215
286
|
}
|
|
216
287
|
else if (config.BRAIN_AUTO_UPDATE) {
|
|
217
|
-
await updateBrainLocked(brainDir, brain.brainId, brain.branch, `${workspaceId}:${brain.brainId}`);
|
|
218
|
-
if (workspaceId)
|
|
288
|
+
const pulled = await updateBrainLocked(brainDir, brain.brainId, brain.branch, `${workspaceId}:${brain.brainId}`);
|
|
289
|
+
if (pulled && workspaceId)
|
|
219
290
|
reportBrainSync(brain.brainId, workspaceId, 'pulled');
|
|
220
291
|
}
|
|
221
292
|
// Idempotent, local-only — covers both the fresh-clone and existing-dir
|
|
@@ -229,14 +300,14 @@ async function getWorkspaceBrainPath(brain, workspaceId) {
|
|
|
229
300
|
*/
|
|
230
301
|
export async function updateBrain(brainDir, brainId, branch, workspaceId) {
|
|
231
302
|
const key = brainLockKey(brainDir, brainId, workspaceId);
|
|
232
|
-
return withBrainLock(key, () => updateBrainLocked(brainDir, brainId, branch, key));
|
|
303
|
+
return withBrainLock(key, () => withGitCredential(brainId, workspaceId, async () => { await updateBrainLocked(brainDir, brainId, branch, key); }));
|
|
233
304
|
}
|
|
234
305
|
async function updateBrainLocked(brainDir, brainId, branch, key) {
|
|
235
306
|
const now = Date.now();
|
|
236
307
|
const lastUpdate = lastUpdateTimes.get(key) || 0;
|
|
237
308
|
if (now - lastUpdate < config.BRAIN_UPDATE_COOLDOWN_MS) {
|
|
238
309
|
logger.debug(`Brain ${brainId} update skipped - cooldown active (${Math.round((config.BRAIN_UPDATE_COOLDOWN_MS - (now - lastUpdate)) / 1000)}s remaining)`);
|
|
239
|
-
return;
|
|
310
|
+
return false;
|
|
240
311
|
}
|
|
241
312
|
// Guard: skip `reset --hard` when the working tree has uncommitted writes or
|
|
242
313
|
// unpushed commits, otherwise an in-flight session's mid-message memory
|
|
@@ -265,7 +336,7 @@ async function updateBrainLocked(brainDir, brainId, branch, key) {
|
|
|
265
336
|
// long-term skip means pushBrainChanges is persistently failing and the brain is
|
|
266
337
|
// silently drifting away from base-brain / skills updates.
|
|
267
338
|
logger.info(`Brain ${brainId} update skipped — local changes present (dirty=${!!dirty.trim()}, hasLocalCommits=${hasLocalCommits})`);
|
|
268
|
-
return;
|
|
339
|
+
return false;
|
|
269
340
|
}
|
|
270
341
|
}
|
|
271
342
|
catch (error) {
|
|
@@ -273,7 +344,7 @@ async function updateBrainLocked(brainDir, brainId, branch, key) {
|
|
|
273
344
|
// the next iteration will retry.
|
|
274
345
|
const errorMessage = startupFailureDetail(error, 'BaseBrain', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO]);
|
|
275
346
|
logger.warn(`Brain ${brainId} pre-check failed, skipping update: ${errorMessage}`);
|
|
276
|
-
return;
|
|
347
|
+
return false;
|
|
277
348
|
}
|
|
278
349
|
await cleanupStaleLocks(brainDir);
|
|
279
350
|
try {
|
|
@@ -283,11 +354,13 @@ async function updateBrainLocked(brainDir, brainId, branch, key) {
|
|
|
283
354
|
});
|
|
284
355
|
lastUpdateTimes.set(key, Date.now());
|
|
285
356
|
logger.info(`Brain ${brainId} updated successfully`);
|
|
357
|
+
return true;
|
|
286
358
|
}
|
|
287
359
|
catch (error) {
|
|
288
360
|
const errorMessage = startupFailureDetail(error, 'BaseBrain', [config.TEAMVIBE_POLLER_TOKEN ?? '', config.BASE_BRAIN_REPO]);
|
|
289
361
|
logger.error(`Failed to update brain ${brainId}: ${errorMessage}`);
|
|
290
362
|
emitError('brain', `Failed to update brain ${brainId}: ${errorMessage}`, { context: { brainId, brainDir } });
|
|
363
|
+
return false;
|
|
291
364
|
}
|
|
292
365
|
}
|
|
293
366
|
/**
|
|
@@ -421,7 +494,7 @@ export async function findOversizedBlobs(brainDir, maxBytes) {
|
|
|
421
494
|
* process-wide env/module-load-order tricks.
|
|
422
495
|
*/
|
|
423
496
|
export async function pushBrainChanges(brainDir, brainId, workspaceId, retryOptions) {
|
|
424
|
-
return withBrainLock(brainLockKey(brainDir, brainId, workspaceId), () => pushBrainChangesLocked(brainDir, brainId, workspaceId, retryOptions));
|
|
497
|
+
return withBrainLock(brainLockKey(brainDir, brainId, workspaceId), () => withGitCredential(brainId, workspaceId, () => pushBrainChangesLocked(brainDir, brainId, workspaceId, retryOptions)));
|
|
425
498
|
}
|
|
426
499
|
async function pushBrainChangesLocked(brainDir, brainId, workspaceId, retryOptions) {
|
|
427
500
|
await cleanupStaleLocks(brainDir);
|
package/dist/brains-bootstrap.js
CHANGED
|
@@ -3,6 +3,7 @@ import { logger } from './logger.js';
|
|
|
3
3
|
import { getPollerId } from './auth-provider.js';
|
|
4
4
|
import { seedTrackedBrains } from './maintenance-cron.js';
|
|
5
5
|
import { emitEvent, emitError } from './event-queue.js';
|
|
6
|
+
import { connectionsClient } from './connections-client.js';
|
|
6
7
|
// Client-side timeout kept comfortably above the server-side route timeout
|
|
7
8
|
// (infra/poller-auth.ts: GET /poller/brains, 25s) so the poller never aborts
|
|
8
9
|
// a request the Lambda could still have answered (DevGuru round-2 review —
|
|
@@ -80,7 +81,7 @@ async function attemptFetch(url, token, timeoutMs) {
|
|
|
80
81
|
if (!body || typeof body !== 'object' || !Array.isArray(body.brains)) {
|
|
81
82
|
return { ok: false, reason: 'malformed-body', detail: 'missing brains array', retryable: false };
|
|
82
83
|
}
|
|
83
|
-
return { ok: true, brains: body.brains };
|
|
84
|
+
return { ok: true, brains: body.brains, channels: body.channels };
|
|
84
85
|
}
|
|
85
86
|
catch (err) {
|
|
86
87
|
const isTimeout = err instanceof Error && err.name === 'AbortError';
|
|
@@ -97,7 +98,8 @@ async function attemptFetch(url, token, timeoutMs) {
|
|
|
97
98
|
}
|
|
98
99
|
/** Seed trackedBrains from a successful fetch and emit success telemetry.
|
|
99
100
|
* Shared by both the critical-path attempt and the detached retry below. */
|
|
100
|
-
function applySuccess(brains) {
|
|
101
|
+
function applySuccess(brains, channels) {
|
|
102
|
+
connectionsClient.refreshTuples(brains, channels);
|
|
101
103
|
const seeded = seedTrackedBrains(brains);
|
|
102
104
|
logger.info(`[Bootstrap] Seeded ${seeded} brain(s) into trackedBrains from backend (${brains.length} returned)`);
|
|
103
105
|
emitEvent('poller.brains_bootstrap.success', 'system', {
|
|
@@ -176,7 +178,7 @@ export async function bootstrapTrackedBrains() {
|
|
|
176
178
|
const url = `${config.TEAMVIBE_API_URL}/poller/brains?pollerId=${encodeURIComponent(pollerId)}`;
|
|
177
179
|
const outcome = await attemptFetch(url, token, CRITICAL_PATH_TIMEOUT_MS);
|
|
178
180
|
if (outcome.ok) {
|
|
179
|
-
applySuccess(outcome.brains);
|
|
181
|
+
applySuccess(outcome.brains, outcome.channels);
|
|
180
182
|
return;
|
|
181
183
|
}
|
|
182
184
|
logger.warn(`[Bootstrap] GET /poller/brains failed on first attempt: ${outcome.reason} (${outcome.detail})`);
|
|
@@ -204,7 +206,7 @@ export async function bootstrapTrackedBrains() {
|
|
|
204
206
|
await sleep(RETRY_DELAY_MS);
|
|
205
207
|
const retryOutcome = await attemptFetch(url, token, FETCH_TIMEOUT_MS);
|
|
206
208
|
if (retryOutcome.ok) {
|
|
207
|
-
applySuccess(retryOutcome.brains);
|
|
209
|
+
applySuccess(retryOutcome.brains, retryOutcome.channels);
|
|
208
210
|
return;
|
|
209
211
|
}
|
|
210
212
|
logger.warn(`[Bootstrap] GET /poller/brains failed on retry: ${retryOutcome.reason} (${retryOutcome.detail})`);
|
package/dist/claude-spawner.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { type ClaudeRateLimitInfo } from './claude-rate-limit.js';
|
|
|
9
9
|
import { type ClaudeQuotaFailure } from './claude-quota.js';
|
|
10
10
|
import { type PoolRuntimeOptions, type PoolAttempt } from './subscription-runtime.js';
|
|
11
11
|
import type { SubscriptionAccount } from './subscription-pool-client.js';
|
|
12
|
+
import { type GitCredential } from './connections-client.js';
|
|
12
13
|
export declare function progressNotificationMarks(estimateMinutes: number): number[];
|
|
13
14
|
export declare function computeCompactionKillDeadline(previousActivityTime: number, now: number, idleKillMs: number, graceMs: number): number;
|
|
14
15
|
export interface ContextHintParams {
|
|
@@ -176,6 +177,8 @@ export interface BuildSpawnEnvParams {
|
|
|
176
177
|
inboxThreadId: string;
|
|
177
178
|
brainPath: string;
|
|
178
179
|
subscriptionAccount?: SubscriptionAccount;
|
|
180
|
+
gitCredential?: GitCredential;
|
|
181
|
+
gitTokenFile?: string;
|
|
179
182
|
}
|
|
180
183
|
/**
|
|
181
184
|
* Pure construction of the env object passed to the spawned `claude` child
|
package/dist/claude-spawner.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { createSessionInstance, writeSessionOwner } from './session-instance.js';
|
|
2
|
+
import { buildGitCredentialEnv } from './git-credential-env.js';
|
|
3
|
+
import { createGitCredentialSession, finishGitCredentialSession, setGitCredentialSessionPid } from './git-credential-session.js';
|
|
1
4
|
import { blockingUsageWindows, normalizePassiveUsage, selectSubscriptionAccount } from './subscription-selection.js';
|
|
2
5
|
import { logPoolFetch, logPoolUsageWrite, poolInfo, poolLabel } from './pool-logging.js';
|
|
3
6
|
import { emitPoolSelection, accountUsageTelemetry, emitCandidateUsage, poolRunTelemetry } from './pool-telemetry.js';
|
|
4
7
|
import { spawn } from 'child_process';
|
|
5
|
-
import { join } from 'path';
|
|
8
|
+
import { join, dirname } from 'path';
|
|
6
9
|
import { readFileSync, existsSync } from 'fs';
|
|
7
10
|
import { WebClient } from '@slack/web-api';
|
|
8
11
|
import { config } from './config.js';
|
|
@@ -21,6 +24,7 @@ import { abortLocalProbe, accountPin, preparePool, fetchRuntimePool, validateAcc
|
|
|
21
24
|
import { selectPoolForResolvedModel } from './subscription-pool.js';
|
|
22
25
|
import { isQuotaPaused, QuotaPausedError } from './quota-pause.js';
|
|
23
26
|
import { redactStartupText } from './startup-auth.js';
|
|
27
|
+
import { connectionsClient } from './connections-client.js';
|
|
24
28
|
/** Inspect Claude's user/project settings without changing any brain files. */
|
|
25
29
|
function warnTokenAliasSettings(claudeConfigDir, cwd, brainPath, sessionLog) {
|
|
26
30
|
// CLAUDE_CONFIG_DIR replaces ~/.claude for user settings. settings.local.json
|
|
@@ -464,7 +468,7 @@ export function buildSpawnEnv(params) {
|
|
|
464
468
|
const exposeToken = !/^(0|false|no|off)$/i.test(params.processEnv['EXPOSE_CLAUDE_TOKEN_TO_AGENT'] ?? '');
|
|
465
469
|
const accountToken = params.subscriptionAccount?.credential.unwrap();
|
|
466
470
|
const sessionToken = accountToken ?? params.processEnv['CLAUDE_CODE_OAUTH_TOKEN'];
|
|
467
|
-
|
|
471
|
+
const env = {
|
|
468
472
|
// Secrets platform (#105) injection — SOLE call site of
|
|
469
473
|
// `SpawnSecretsEnvelope.unwrap()` in the entire poller codebase.
|
|
470
474
|
// Placed FIRST so subsequent spreads can overwrite. Belt-and-
|
|
@@ -530,6 +534,12 @@ export function buildSpawnEnv(params) {
|
|
|
530
534
|
CLAUDE_CODE_USE_FOUNDRY: undefined,
|
|
531
535
|
} : {}),
|
|
532
536
|
};
|
|
537
|
+
const git = params.gitCredential ?? { source: 'none' };
|
|
538
|
+
const gitEnv = buildGitCredentialEnv(env, git, params.gitTokenFile);
|
|
539
|
+
if (git.source === 'platform' && params.gitTokenFile) {
|
|
540
|
+
gitEnv['TV_GIT_BG_TASKS_FILE'] = join(dirname(params.gitTokenFile), 'bg-tasks');
|
|
541
|
+
}
|
|
542
|
+
return gitEnv;
|
|
533
543
|
}
|
|
534
544
|
export function buildPrompt(msg, contextHint) {
|
|
535
545
|
const isEmail = msg.source === 'email';
|
|
@@ -848,7 +858,7 @@ function resultMetrics(event, pooled, lastAssistant) {
|
|
|
848
858
|
metrics.context_window = window;
|
|
849
859
|
return metrics;
|
|
850
860
|
}
|
|
851
|
-
async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, runtime) {
|
|
861
|
+
async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator, runtime, gitDelivery = {}) {
|
|
852
862
|
const slackContext = msg.response_context.slack;
|
|
853
863
|
const isCronMessage = msg.source === 'cron';
|
|
854
864
|
const isMaintenance = msg.source === 'maintenance';
|
|
@@ -886,6 +896,10 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
|
|
|
886
896
|
workspaceId: msg.teamvibe.workspaceId,
|
|
887
897
|
channelId: msg.teamvibe.channelId,
|
|
888
898
|
});
|
|
899
|
+
const gitCredential = gitDelivery.credential ??= await connectionsClient.get('channel', msg.teamvibe.channelId);
|
|
900
|
+
if (gitCredential.source === 'platform' && !gitDelivery.session) {
|
|
901
|
+
gitDelivery.session = createGitCredentialSession(gitCredential, { channelId: msg.teamvibe.channelId, threadId: msg.thread_id, instance: gitDelivery.instance });
|
|
902
|
+
}
|
|
889
903
|
const secretsReminder = buildSecretsReminder(spawnSecrets.reminders);
|
|
890
904
|
// Fetch scoped model config for this spawn — same hoisting rationale as
|
|
891
905
|
// secrets above. Fail-soft: returns {} on any error, which resolveModel
|
|
@@ -1005,10 +1019,12 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
|
|
|
1005
1019
|
inboxThreadId: msg.thread_id,
|
|
1006
1020
|
brainPath: directory.path,
|
|
1007
1021
|
subscriptionAccount: runtime?.account,
|
|
1022
|
+
gitCredential,
|
|
1023
|
+
gitTokenFile: gitDelivery.session?.tokenFile,
|
|
1008
1024
|
});
|
|
1009
1025
|
// Capture the actual credentials for this attempt, including pool selection
|
|
1010
1026
|
// and secrets overlays, regardless of whether alias exposure is enabled.
|
|
1011
|
-
const outputTokens = [spawnEnv['CLAUDE_CODE_OAUTH_TOKEN'], spawnEnv['TEAMVIBE_CLAUDE_OAUTH_TOKEN']]
|
|
1027
|
+
const outputTokens = [spawnEnv['CLAUDE_CODE_OAUTH_TOKEN'], spawnEnv['TEAMVIBE_CLAUDE_OAUTH_TOKEN'], spawnEnv['GH_TOKEN'], spawnEnv['GITHUB_TOKEN']]
|
|
1012
1028
|
.filter((token) => !!token);
|
|
1013
1029
|
const redactOutput = (text) => redactStartupText(text, outputTokens);
|
|
1014
1030
|
const safeEvent = (value) => {
|
|
@@ -1020,7 +1036,7 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
|
|
|
1020
1036
|
return value;
|
|
1021
1037
|
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
|
|
1022
1038
|
redactOutput(key),
|
|
1023
|
-
/^(?:CLAUDE_CODE_OAUTH_TOKEN|TEAMVIBE_CLAUDE_OAUTH_TOKEN)$/i.test(key) ? '[REDACTED]' : safeEvent(entry),
|
|
1039
|
+
/^(?:CLAUDE_CODE_OAUTH_TOKEN|TEAMVIBE_CLAUDE_OAUTH_TOKEN|GH_TOKEN|GITHUB_TOKEN|GITLAB_TOKEN)$/i.test(key) ? '[REDACTED]' : safeEvent(entry),
|
|
1024
1040
|
]));
|
|
1025
1041
|
};
|
|
1026
1042
|
warnTokenAliasSettings(spawnEnv['CLAUDE_CONFIG_DIR'], cwd, directory.path, sessionLog);
|
|
@@ -1042,6 +1058,8 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
|
|
|
1042
1058
|
// itself.
|
|
1043
1059
|
detached: true,
|
|
1044
1060
|
});
|
|
1061
|
+
if (gitDelivery.session)
|
|
1062
|
+
setGitCredentialSessionPid(gitDelivery.session, proc.pid);
|
|
1045
1063
|
// Track active process by message ID and thread ID
|
|
1046
1064
|
activeProcesses.set(msg.id, proc);
|
|
1047
1065
|
activeProcessesByThread.set(msg.thread_id, proc);
|
|
@@ -1467,10 +1485,11 @@ export async function spawnClaudeCode(msg, sessionLog, selectedDirectory, sessio
|
|
|
1467
1485
|
// the main working dir can't trample in-flight writes or pre-push commits.
|
|
1468
1486
|
// See teamvibe.ai#120. cwd remains the main brain path on fallback / failure.
|
|
1469
1487
|
let effectiveCwd = cwd;
|
|
1488
|
+
const gitDelivery = { instance: createSessionInstance() };
|
|
1470
1489
|
let worktreeOwner;
|
|
1471
1490
|
if (shouldIsolateInWorktree(msg)) {
|
|
1472
1491
|
try {
|
|
1473
|
-
effectiveCwd = await createWorktree(cwd, msg.thread_id);
|
|
1492
|
+
effectiveCwd = await createWorktree(cwd, msg.thread_id, 'main', gitDelivery.instance);
|
|
1474
1493
|
worktreeOwner = { brainPath: cwd, id: msg.thread_id };
|
|
1475
1494
|
sessionLog.info(`[Worktree] Spawned session running in ${effectiveCwd}`);
|
|
1476
1495
|
}
|
|
@@ -1487,12 +1506,12 @@ export async function spawnClaudeCode(msg, sessionLog, selectedDirectory, sessio
|
|
|
1487
1506
|
const { randomUUID } = await import('crypto');
|
|
1488
1507
|
effectiveSessionId = randomUUID();
|
|
1489
1508
|
}
|
|
1490
|
-
const result = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, effectiveSessionId, isFirstMessage, lastMessageTs, onMessageSent, typingIndicator);
|
|
1509
|
+
const result = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, effectiveSessionId, isFirstMessage, lastMessageTs, onMessageSent, typingIndicator, undefined, gitDelivery);
|
|
1491
1510
|
// If --resume failed, retry as a fresh session (session files may have been lost on container restart)
|
|
1492
1511
|
let retryResult = result;
|
|
1493
1512
|
if (!result.success && !result.quota && effectiveSessionId && !isFirstMessage) {
|
|
1494
1513
|
sessionLog.info('Resume failed, retrying as fresh session (session files may have been lost)');
|
|
1495
|
-
retryResult = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, effectiveSessionId, true, lastMessageTs, onMessageSent, typingIndicator);
|
|
1514
|
+
retryResult = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, effectiveSessionId, true, lastMessageTs, onMessageSent, typingIndicator, undefined, gitDelivery);
|
|
1496
1515
|
}
|
|
1497
1516
|
// If session ID is "already in use" (stale lock file), retry immediately with a
|
|
1498
1517
|
// fresh ID — no zombie kill first. Zombie cleanup is #73's concern, not spawn
|
|
@@ -1501,17 +1520,23 @@ export async function spawnClaudeCode(msg, sessionLog, selectedDirectory, sessio
|
|
|
1501
1520
|
sessionLog.info('Session ID already in use (stale lock), retrying with fresh session ID');
|
|
1502
1521
|
const { randomUUID } = await import('crypto');
|
|
1503
1522
|
const freshId = randomUUID();
|
|
1504
|
-
const freshResult = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, freshId, true, lastMessageTs, onMessageSent, typingIndicator);
|
|
1523
|
+
const freshResult = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, freshId, true, lastMessageTs, onMessageSent, typingIndicator, undefined, gitDelivery);
|
|
1505
1524
|
return finalizeSpawnResult(freshResult, true, freshId);
|
|
1506
1525
|
}
|
|
1507
1526
|
return finalizeSpawnResult(retryResult, isFirstMessage, effectiveSessionId);
|
|
1508
1527
|
}
|
|
1509
1528
|
finally {
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1529
|
+
const cleanup = () => {
|
|
1530
|
+
if (worktreeOwner) {
|
|
1531
|
+
removeWorktree(worktreeOwner.brainPath, worktreeOwner.id, gitDelivery.instance).catch((err) => {
|
|
1532
|
+
sessionLog.warn(`[Worktree] Cleanup failed (will be retried by orphan janitor): ${err instanceof Error ? err.message : err}`);
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
};
|
|
1536
|
+
if (gitDelivery.session)
|
|
1537
|
+
finishGitCredentialSession(gitDelivery.session, cleanup);
|
|
1538
|
+
else
|
|
1539
|
+
cleanup();
|
|
1515
1540
|
}
|
|
1516
1541
|
}
|
|
1517
1542
|
// Configured-pool orchestration is deliberately separate from legacy spawns.
|
|
@@ -1543,6 +1568,7 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
|
|
|
1543
1568
|
let effectiveCwd = cwd;
|
|
1544
1569
|
let retainWorktree = resumePending;
|
|
1545
1570
|
const poolAttempts = poolRuntime?.attempts?.slice(-1000) ?? [];
|
|
1571
|
+
const gitDelivery = { instance: createSessionInstance() };
|
|
1546
1572
|
let worktreeOwner;
|
|
1547
1573
|
if (isolate) {
|
|
1548
1574
|
try {
|
|
@@ -1550,7 +1576,8 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
|
|
|
1550
1576
|
if (resumePending && !existsSync(derivedCwd)) {
|
|
1551
1577
|
sessionLog.warn(`[Worktree] Pinned worktree missing; recreating ${derivedCwd} for pending session ${sessionId ?? 'unknown'}`);
|
|
1552
1578
|
}
|
|
1553
|
-
effectiveCwd = resumePending && existsSync(derivedCwd) ? derivedCwd : await createWorktree(cwd, msg.thread_id);
|
|
1579
|
+
effectiveCwd = resumePending && existsSync(derivedCwd) ? derivedCwd : await createWorktree(cwd, msg.thread_id, 'main', gitDelivery.instance);
|
|
1580
|
+
writeSessionOwner(join(effectiveCwd, '.teamvibe-session-owner.json'), gitDelivery.instance);
|
|
1554
1581
|
worktreeOwner = { brainPath: cwd, id: msg.thread_id };
|
|
1555
1582
|
sessionLog.info(`[Worktree] Spawned session running in ${effectiveCwd}`);
|
|
1556
1583
|
}
|
|
@@ -1621,7 +1648,7 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
|
|
|
1621
1648
|
// fallback must report the fresh attempt's delivery flags independently.
|
|
1622
1649
|
if (!runtime?.resumePending)
|
|
1623
1650
|
sawSendMessageCall = false;
|
|
1624
|
-
const spawned = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, id, runtime?.resumePending ? false : first, lastMessageTs, onMessageSent, typingIndicator, runtime);
|
|
1651
|
+
const spawned = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, id, runtime?.resumePending ? false : first, lastMessageTs, onMessageSent, typingIndicator, runtime, gitDelivery);
|
|
1625
1652
|
const result = prepared?.account
|
|
1626
1653
|
? { ...spawned, quota: quotaForModel(spawned.quota), success: rejectedForModel(spawned.rateLimitInfo) ? false : spawned.success } : spawned;
|
|
1627
1654
|
sawSendMessageCall ||= result.sawSendMessageCall === true;
|
|
@@ -1766,11 +1793,17 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
|
|
|
1766
1793
|
throw error;
|
|
1767
1794
|
}
|
|
1768
1795
|
finally {
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1796
|
+
const cleanup = () => {
|
|
1797
|
+
if (worktreeOwner && !retainWorktree) {
|
|
1798
|
+
removeWorktree(worktreeOwner.brainPath, worktreeOwner.id, gitDelivery.instance).catch((err) => {
|
|
1799
|
+
sessionLog.warn(`[Worktree] Cleanup failed (will be retried by orphan janitor): ${err instanceof Error ? err.message : err}`);
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
};
|
|
1803
|
+
if (gitDelivery.session)
|
|
1804
|
+
finishGitCredentialSession(gitDelivery.session, cleanup);
|
|
1805
|
+
else
|
|
1806
|
+
cleanup();
|
|
1774
1807
|
}
|
|
1775
1808
|
}
|
|
1776
1809
|
const activeProcesses = new Map();
|