@teamvibe/poller 0.8.8 → 0.8.9-rc.1
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/README.md +5 -0
- package/bin/tv-git-credential.mjs +11 -6
- package/dist/brain-manager.js +4 -4
- package/dist/claude-spawner.d.ts +2 -2
- package/dist/claude-spawner.js +8 -4
- package/dist/config.d.ts +4 -0
- package/dist/config.js +1 -0
- package/dist/connections-client.d.ts +16 -4
- package/dist/connections-client.js +42 -11
- package/dist/end-of-turn-sentinel.d.ts +2 -0
- package/dist/end-of-turn-sentinel.js +2 -0
- package/dist/git-credential-bindings.d.ts +2 -0
- package/dist/git-credential-bindings.js +4 -0
- package/dist/git-credential-env.d.ts +2 -2
- package/dist/git-credential-env.js +22 -9
- package/dist/git-credential-session.d.ts +6 -6
- package/dist/git-credential-session.js +24 -4
- package/dist/inbox-watcher.d.ts +2 -2
- package/dist/inbox-watcher.js +20 -13
- package/dist/poller.d.ts +4 -3
- package/dist/poller.js +43 -8
- package/dist/redact-text.js +1 -1
- package/dist/slack-client.d.ts +3 -1
- package/dist/slack-client.js +84 -12
- package/dist/thread-routing.d.ts +7 -0
- package/dist/thread-routing.js +7 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -99,3 +99,8 @@ The poller authenticates with the TeamVibe API using your token, then continuous
|
|
|
99
99
|
## Subscription usage freshness
|
|
100
100
|
|
|
101
101
|
Passive usage percentages remain usable lower bounds until each window's `resetAt`. Without a reset time, passive windows expire five minutes after observation. Probe windows retain their 60-second TTL. Reset windows are ignored; known accounts still rank before unknown accounts. A window at the account's threshold defers selection until its expiry (reset for passive windows, TTL fallback without a reset).
|
|
102
|
+
|
|
103
|
+
## Channel Git credentials
|
|
104
|
+
|
|
105
|
+
See [Agent Git credentials](docs/git-credentials.md) for multi-host channel bindings,
|
|
106
|
+
provider CLI environment variables, helper matching and session file lifetime.
|
|
@@ -16,15 +16,20 @@ if (process.argv[2] === 'get') {
|
|
|
16
16
|
if (separator > 0) request[line.slice(0, separator)] = line.slice(separator + 1)
|
|
17
17
|
}
|
|
18
18
|
const file = process.env.TV_GIT_TOKEN_FILE
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
const stored = JSON.parse(readFileSync(file, 'utf8'))
|
|
20
|
+
const bindings = Array.isArray(stored) ? stored : [stored]
|
|
21
|
+
if (request.protocol !== 'https' || typeof request.host !== 'string' || request.host !== request.host.toLowerCase()) process.exit(0)
|
|
22
|
+
const credential = bindings.find(binding => binding?.host === request.host)
|
|
23
|
+
if (!credential ||
|
|
24
|
+
typeof credential.token !== 'string' ||
|
|
22
25
|
!credential.token || /[\r\n\0]/.test(credential.token) ||
|
|
23
|
-
!['github_token', 'github_app'].includes(credential.kind)) process.exit(0)
|
|
26
|
+
!['github_token', 'github_app', 'gitlab_token'].includes(credential.kind)) process.exit(0)
|
|
27
|
+
const username = credential.username ?? (credential.kind === 'gitlab_token' ? 'oauth2' : 'x-access-token')
|
|
28
|
+
if (!['x-access-token', 'oauth2'].includes(username)) process.exit(0)
|
|
24
29
|
if (credential.expiresAt !== undefined) {
|
|
25
30
|
const expiry = Date.parse(credential.expiresAt)
|
|
26
31
|
if (!Number.isFinite(expiry) || expiry <= Date.now()) {
|
|
27
|
-
const diagnostic = `git_credential_expired kind=${credential.kind}\n`
|
|
32
|
+
const diagnostic = `git_credential_expired kind=${credential.kind}${Array.isArray(stored) ? ` host=${credential.host}` : ''}\n`
|
|
28
33
|
process.stderr.write(diagnostic)
|
|
29
34
|
// Value-free spool lets the poller report detached-task expiry through
|
|
30
35
|
// its usual error queue even after the launching agent has exited.
|
|
@@ -32,7 +37,7 @@ if (process.argv[2] === 'get') {
|
|
|
32
37
|
process.exit(0)
|
|
33
38
|
}
|
|
34
39
|
}
|
|
35
|
-
process.stdout.write(`username
|
|
40
|
+
process.stdout.write(`username=${username}\npassword=${credential.token}\n`)
|
|
36
41
|
} catch {
|
|
37
42
|
// No output and success means Git proceeds to its prompt-disabled failure.
|
|
38
43
|
}
|
package/dist/brain-manager.js
CHANGED
|
@@ -29,7 +29,7 @@ if (config.PERSISTENT_STORAGE_PATH) {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
const gitCredentialContext = new AsyncLocalStorage();
|
|
32
|
-
const
|
|
32
|
+
const httpsBrains = new Set();
|
|
33
33
|
async function gitExec(command, options) {
|
|
34
34
|
const context = gitCredentialContext.getStore();
|
|
35
35
|
const run = async (credential) => {
|
|
@@ -86,7 +86,7 @@ async function gitExec(command, options) {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
async function withGitCredential(brainId, workspaceId, fn) {
|
|
89
|
-
if (!workspaceId || !
|
|
89
|
+
if (!workspaceId || !httpsBrains.has(brainId) || !config.TEAMVIBE_API_URL || !config.TEAMVIBE_POLLER_TOKEN)
|
|
90
90
|
return fn();
|
|
91
91
|
const credential = await connectionsClient.get('brain', brainId);
|
|
92
92
|
return gitCredentialContext.run({ brainId, credential }, fn);
|
|
@@ -253,8 +253,8 @@ export async function getBrainPath(brain, channelId, workspaceId) {
|
|
|
253
253
|
return config.DEFAULT_BRAIN_PATH;
|
|
254
254
|
}
|
|
255
255
|
const brainDir = resolveBrainDir(workspaceId, brain.brainId);
|
|
256
|
-
if (/^https
|
|
257
|
-
|
|
256
|
+
if (/^https:\/\//i.test(brain.gitRepoUrl))
|
|
257
|
+
httpsBrains.add(brain.brainId);
|
|
258
258
|
return withBrainLock(brainLockKey(brainDir, brain.brainId, workspaceId), () => withGitCredential(brain.brainId, workspaceId, () => getWorkspaceBrainPath(brain, workspaceId)));
|
|
259
259
|
}
|
|
260
260
|
async function getWorkspaceBrainPath(brain, workspaceId) {
|
package/dist/claude-spawner.d.ts
CHANGED
|
@@ -9,7 +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
|
+
import { type GitCredential, type GitCredentialSet } from './connections-client.js';
|
|
13
13
|
export declare function progressNotificationMarks(estimateMinutes: number): number[];
|
|
14
14
|
export declare function computeCompactionKillDeadline(previousActivityTime: number, now: number, idleKillMs: number, graceMs: number): number;
|
|
15
15
|
export interface ContextHintParams {
|
|
@@ -179,7 +179,7 @@ export interface BuildSpawnEnvParams {
|
|
|
179
179
|
inboxThreadId: string;
|
|
180
180
|
brainPath: string;
|
|
181
181
|
subscriptionAccount?: SubscriptionAccount;
|
|
182
|
-
gitCredential?: GitCredential;
|
|
182
|
+
gitCredential?: GitCredential | GitCredentialSet;
|
|
183
183
|
gitTokenFile?: string;
|
|
184
184
|
}
|
|
185
185
|
/**
|
package/dist/claude-spawner.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL } from './end-of-turn-sentinel.js';
|
|
1
2
|
import { createSessionInstance, writeSessionOwner } from './session-instance.js';
|
|
2
3
|
import { buildGitCredentialEnv } from './git-credential-env.js';
|
|
3
4
|
import { createGitCredentialSession, finishGitCredentialSession, setGitCredentialSessionPid } from './git-credential-session.js';
|
|
@@ -794,10 +795,10 @@ function resolveTypingStatusDetail(event, assistantText) {
|
|
|
794
795
|
if (assistantCalledTool(event, 'mcp__slack__send_message'))
|
|
795
796
|
return null;
|
|
796
797
|
const text = assistantText ?? extractAssistantText(event);
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
return { text: oneLineStatus(
|
|
798
|
+
// tv#589: the silence marker is control text, including inside narration.
|
|
799
|
+
const narration = text?.split(END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL).join('').replace(/\s+/g, ' ').trim();
|
|
800
|
+
if (narration) {
|
|
801
|
+
return { text: oneLineStatus(narration), source: 'comment' };
|
|
801
802
|
}
|
|
802
803
|
for (const block of event.message.content) {
|
|
803
804
|
if (block?.type !== 'tool_use' || typeof block.name !== 'string')
|
|
@@ -809,6 +810,9 @@ function resolveTypingStatusDetail(event, assistantText) {
|
|
|
809
810
|
return { text: oneLineStatus(status), source: `tool:${safeToolName}` };
|
|
810
811
|
}
|
|
811
812
|
}
|
|
813
|
+
// A sentinel-only event keeps the current status when no tool status applies.
|
|
814
|
+
if (text?.includes(END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL))
|
|
815
|
+
return null;
|
|
812
816
|
return { text: 'Thinking...', source: 'default' };
|
|
813
817
|
}
|
|
814
818
|
export function resolveTypingStatus(event, assistantText) {
|
package/dist/config.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
19
19
|
NETWORK_ERROR_KILL_MS: z.ZodDefault<z.ZodNumber>;
|
|
20
20
|
MAX_CONSECUTIVE_NET_FAILURES: z.ZodDefault<z.ZodNumber>;
|
|
21
21
|
TYPING_COMMENT_MIN_HOLD_MS: z.ZodDefault<z.ZodNumber>;
|
|
22
|
+
TYPING_STATUS_REQUEST_TIMEOUT_MS: z.ZodDefault<z.ZodNumber>;
|
|
22
23
|
TEAMVIBE_DATA_DIR: z.ZodDefault<z.ZodString>;
|
|
23
24
|
BRAINS_PATH: z.ZodDefault<z.ZodString>;
|
|
24
25
|
DEFAULT_BRAIN_PATH: z.ZodDefault<z.ZodString>;
|
|
@@ -63,6 +64,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
63
64
|
NETWORK_ERROR_KILL_MS: number;
|
|
64
65
|
MAX_CONSECUTIVE_NET_FAILURES: number;
|
|
65
66
|
TYPING_COMMENT_MIN_HOLD_MS: number;
|
|
67
|
+
TYPING_STATUS_REQUEST_TIMEOUT_MS: number;
|
|
66
68
|
TEAMVIBE_DATA_DIR: string;
|
|
67
69
|
BRAINS_PATH: string;
|
|
68
70
|
DEFAULT_BRAIN_PATH: string;
|
|
@@ -113,6 +115,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
113
115
|
NETWORK_ERROR_KILL_MS?: number | undefined;
|
|
114
116
|
MAX_CONSECUTIVE_NET_FAILURES?: number | undefined;
|
|
115
117
|
TYPING_COMMENT_MIN_HOLD_MS?: number | undefined;
|
|
118
|
+
TYPING_STATUS_REQUEST_TIMEOUT_MS?: number | undefined;
|
|
116
119
|
TEAMVIBE_DATA_DIR?: string | undefined;
|
|
117
120
|
BRAINS_PATH?: string | undefined;
|
|
118
121
|
DEFAULT_BRAIN_PATH?: string | undefined;
|
|
@@ -160,6 +163,7 @@ export declare const config: {
|
|
|
160
163
|
NETWORK_ERROR_KILL_MS: number;
|
|
161
164
|
MAX_CONSECUTIVE_NET_FAILURES: number;
|
|
162
165
|
TYPING_COMMENT_MIN_HOLD_MS: number;
|
|
166
|
+
TYPING_STATUS_REQUEST_TIMEOUT_MS: number;
|
|
163
167
|
TEAMVIBE_DATA_DIR: string;
|
|
164
168
|
BRAINS_PATH: string;
|
|
165
169
|
DEFAULT_BRAIN_PATH: string;
|
package/dist/config.js
CHANGED
|
@@ -28,6 +28,7 @@ const configSchema = z.object({
|
|
|
28
28
|
NETWORK_ERROR_KILL_MS: z.coerce.number().default(120000), // Per-session: kill stuck session after 2 min of network errors
|
|
29
29
|
MAX_CONSECUTIVE_NET_FAILURES: z.coerce.number().default(5), // Poll loop: DNS flush at N, exit(1) at 2×N consecutive failures
|
|
30
30
|
TYPING_COMMENT_MIN_HOLD_MS: z.coerce.number().default(4500), // Minimum time to display agent narration before a canned status
|
|
31
|
+
TYPING_STATUS_REQUEST_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000), // Abort stalled Slack status requests
|
|
31
32
|
// Paths
|
|
32
33
|
TEAMVIBE_DATA_DIR: z.string().default(DEFAULT_DATA_DIR),
|
|
33
34
|
BRAINS_PATH: z.string().default(''),
|
|
@@ -1,13 +1,24 @@
|
|
|
1
|
+
export type GitCredentialKind = 'github_token' | 'github_app' | 'gitlab_token';
|
|
1
2
|
export type GitCredential = {
|
|
2
3
|
source: 'platform';
|
|
3
|
-
host:
|
|
4
|
-
username: 'x-access-token';
|
|
4
|
+
host: string;
|
|
5
|
+
username: 'x-access-token' | 'oauth2';
|
|
5
6
|
token: string;
|
|
6
7
|
connectionId: string;
|
|
7
8
|
credentialVersion: string;
|
|
8
9
|
bindingVersion: number;
|
|
9
10
|
expiresAt?: string;
|
|
10
|
-
kind
|
|
11
|
+
kind: GitCredentialKind;
|
|
12
|
+
} | {
|
|
13
|
+
source: 'none';
|
|
14
|
+
legacyAllowed?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type PlatformCredential = Omit<Extract<GitCredential, {
|
|
17
|
+
source: 'platform';
|
|
18
|
+
}>, 'source'>;
|
|
19
|
+
export type GitCredentialSet = {
|
|
20
|
+
source: 'platform';
|
|
21
|
+
bindings: PlatformCredential[];
|
|
11
22
|
} | {
|
|
12
23
|
source: 'none';
|
|
13
24
|
legacyAllowed?: boolean;
|
|
@@ -59,7 +70,8 @@ export declare class ConnectionsClient {
|
|
|
59
70
|
recordBrainSource(brainId: string, source: 'platform' | 'legacy'): void;
|
|
60
71
|
get legacyGitBrains(): number;
|
|
61
72
|
private noticeLegacyServer;
|
|
62
|
-
get(scope:
|
|
73
|
+
get(scope: 'brain', id: string, force?: boolean): Promise<GitCredential>;
|
|
74
|
+
get(scope: Scope, id: string, force?: boolean): Promise<GitCredential | GitCredentialSet>;
|
|
63
75
|
}
|
|
64
76
|
export declare const connectionsClient: ConnectionsClient;
|
|
65
77
|
export declare function refreshConnectionTuples(pollerId: string): Promise<void>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gitCredentialBindings } from './git-credential-bindings.js';
|
|
1
2
|
import { config } from './config.js';
|
|
2
3
|
import { registerConfiguredToken } from './redact-text.js';
|
|
3
4
|
export class ConnectionFetchError extends Error {
|
|
@@ -9,6 +10,22 @@ export class ConnectionFetchError extends Error {
|
|
|
9
10
|
this.status = status;
|
|
10
11
|
}
|
|
11
12
|
}
|
|
13
|
+
// RFC 1123 hostname: lowercase labels of 1–63 characters, 253 total,
|
|
14
|
+
// with no scheme, port, path, trailing dot or leading/trailing hyphens.
|
|
15
|
+
function isCredentialHost(host) {
|
|
16
|
+
return typeof host === 'string' && host.length <= 253 && !/[^a-z0-9.-]/.test(host) &&
|
|
17
|
+
host.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
|
|
18
|
+
}
|
|
19
|
+
function isPlatformCredential(value) {
|
|
20
|
+
if (!value || typeof value !== 'object')
|
|
21
|
+
return false;
|
|
22
|
+
const body = value;
|
|
23
|
+
return isCredentialHost(body.host) &&
|
|
24
|
+
((body.kind === 'github_token' || body.kind === 'github_app') && body.host === 'github.com' && body.username === 'x-access-token' ||
|
|
25
|
+
body.kind === 'gitlab_token' && body.host !== 'github.com' && body.username === 'oauth2') &&
|
|
26
|
+
typeof body.token === 'string' && !!body.token && typeof body.connectionId === 'string' &&
|
|
27
|
+
typeof body.credentialVersion === 'string' && typeof body.bindingVersion === 'number';
|
|
28
|
+
}
|
|
12
29
|
/** Cache keys include consumer scope. Binding tuples arrive at startup and
|
|
13
30
|
* each brains refresh; a changed tuple evicts immediately. */
|
|
14
31
|
export class ConnectionsClient {
|
|
@@ -75,8 +92,10 @@ export class ConnectionsClient {
|
|
|
75
92
|
if (!/^[A-Za-z0-9_-]{1,128}$/.test(id))
|
|
76
93
|
throw new ConnectionFetchError('invalid_request', 400);
|
|
77
94
|
const url = new URL(scope === 'brain' ? `/brains/${encodeURIComponent(id)}/git-credential` : '/connections/git/spawn', this.options.apiUrl);
|
|
78
|
-
if (scope === 'channel')
|
|
95
|
+
if (scope === 'channel') {
|
|
79
96
|
url.searchParams.set('channelId', id);
|
|
97
|
+
url.searchParams.set('format', 'bindings');
|
|
98
|
+
}
|
|
80
99
|
const response = await (this.options.fetch ?? fetch)(url, { headers: { Authorization: `Bearer ${this.options.pollerToken}`, 'Cache-Control': 'no-store' }, redirect: 'error' });
|
|
81
100
|
if (!response.ok) {
|
|
82
101
|
if (response.status === 405 || response.status === 404 && !(await response.clone().json().catch(() => ({}))).code) {
|
|
@@ -87,20 +106,32 @@ export class ConnectionsClient {
|
|
|
87
106
|
const code = { 400: 'invalid_request', 401: 'unauthorized', 403: 'forbidden', 404: 'connection_unbound', 409: 'connection_disabled', 410: 'connection_deleted' }[response.status] ?? 'credential_store_unavailable';
|
|
88
107
|
throw new ConnectionFetchError(code, response.status);
|
|
89
108
|
}
|
|
90
|
-
const body = await response.json();
|
|
109
|
+
const body = await response.json().catch(() => null);
|
|
91
110
|
let value;
|
|
92
|
-
if (body
|
|
93
|
-
value = { source: 'none', ...(body
|
|
94
|
-
else if (body
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
111
|
+
if (body?.['source'] === 'none')
|
|
112
|
+
value = { source: 'none', ...(body['legacyAllowed'] === true ? { legacyAllowed: true } : {}) };
|
|
113
|
+
else if (body?.['source'] === 'platform') {
|
|
114
|
+
if (scope === 'channel') {
|
|
115
|
+
// Older servers may ignore format=bindings; tolerate their single body.
|
|
116
|
+
const { source: _, ...single } = body;
|
|
117
|
+
const bindings = Object.hasOwn(body, 'bindings') ? body['bindings'] : [single];
|
|
118
|
+
if (!Array.isArray(bindings) || bindings.length === 0 || !bindings.every(isPlatformCredential) ||
|
|
119
|
+
new Set(bindings.map(binding => binding.host.toLowerCase())).size !== bindings.length)
|
|
120
|
+
throw new ConnectionFetchError('invalid_response', 503);
|
|
121
|
+
value = { source: 'platform', bindings };
|
|
122
|
+
}
|
|
123
|
+
else if (isPlatformCredential(body))
|
|
124
|
+
value = body;
|
|
125
|
+
else
|
|
126
|
+
throw new ConnectionFetchError('invalid_response', 503);
|
|
127
|
+
for (const binding of gitCredentialBindings(value))
|
|
128
|
+
registerConfiguredToken(binding.token);
|
|
99
129
|
}
|
|
100
130
|
else
|
|
101
131
|
throw new ConnectionFetchError('invalid_response', 503);
|
|
102
|
-
const
|
|
103
|
-
|
|
132
|
+
const bindings = gitCredentialBindings(value);
|
|
133
|
+
const validUntil = bindings.length ? Math.min(...bindings.map(binding => binding.expiresAt
|
|
134
|
+
? Date.parse(binding.expiresAt) - 5 * 60_000 : this.now() + 10 * 60_000)) : this.now() + 10 * 60_000;
|
|
104
135
|
if (validUntil > this.now())
|
|
105
136
|
this.entries.set(key, { value, tuple: tuple ?? '', validUntil });
|
|
106
137
|
return value;
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type { GitCredential } from './connections-client.js';
|
|
1
|
+
import type { GitCredential, GitCredentialSet } from './connections-client.js';
|
|
2
2
|
export declare const GIT_CREDENTIAL_HELPER: string;
|
|
3
|
-
export declare function buildGitCredentialEnv(base: NodeJS.ProcessEnv, credential: GitCredential, tokenFile?: string): NodeJS.ProcessEnv;
|
|
3
|
+
export declare function buildGitCredentialEnv(base: NodeJS.ProcessEnv, credential: GitCredential | GitCredentialSet, tokenFile?: string): NodeJS.ProcessEnv;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gitCredentialBindings } from './git-credential-bindings.js';
|
|
1
2
|
import { fileURLToPath } from 'node:url';
|
|
2
3
|
import { registerConfiguredToken } from './redact-text.js';
|
|
3
4
|
// Works from both src/ (tests) and dist/ (published package).
|
|
@@ -9,29 +10,41 @@ export function buildGitCredentialEnv(base, credential, tokenFile) {
|
|
|
9
10
|
// Do not inherit a prior session's helper/file, even on eligible legacy.
|
|
10
11
|
if (key.startsWith('TV_GIT_') || !legacy && (/^GIT_(?:CONFIG|CREDENTIAL|TRACE|SSL|HTTP|SSH)(?:_|$)/.test(key) ||
|
|
11
12
|
['GIT_ASKPASS', 'GIT_EXEC_PATH', 'GIT_PROXY_COMMAND', 'GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE', 'GIT_NAMESPACE', 'GIT_TEMPLATE_DIR', 'GIT_PROTOCOL', 'GIT_ALLOW_PROTOCOL', 'SSH_ASKPASS'].includes(key) ||
|
|
12
|
-
['GITHUB_TOKEN', 'GH_TOKEN', 'GITLAB_TOKEN'].includes(key)))
|
|
13
|
+
['GITHUB_TOKEN', 'GH_TOKEN', 'GITLAB_TOKEN', 'GITLAB_HOST'].includes(key)))
|
|
13
14
|
delete env[key];
|
|
14
15
|
}
|
|
15
16
|
if (legacy)
|
|
16
17
|
return env;
|
|
17
18
|
env['GIT_TERMINAL_PROMPT'] = '0';
|
|
18
|
-
|
|
19
|
+
const bindings = gitCredentialBindings(credential);
|
|
20
|
+
env['GIT_CONFIG_COUNT'] = String(1 + 2 * bindings.length);
|
|
19
21
|
env['GIT_CONFIG_KEY_0'] = 'credential.helper';
|
|
20
22
|
env['GIT_CONFIG_VALUE_0'] = '';
|
|
21
23
|
if (credential.source === 'platform') {
|
|
22
24
|
if (!tokenFile)
|
|
23
25
|
throw new Error('Git credential token file required');
|
|
24
|
-
registerConfiguredToken(credential.token);
|
|
25
26
|
env['TV_GIT_TOKEN_FILE'] = tokenFile;
|
|
26
|
-
env['GIT_CONFIG_KEY_1'] = `credential.https://${credential.host}.helper`;
|
|
27
27
|
// Strip CLI snapshots before the helper process starts. Git appends only
|
|
28
28
|
// get/store/erase; the token remains exclusively in the private file.
|
|
29
29
|
const helperPath = "'" + GIT_CREDENTIAL_HELPER.replace(/'/g, "'\\''") + "'";
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
for (const [index, binding] of bindings.entries()) {
|
|
31
|
+
registerConfiguredToken(binding.token);
|
|
32
|
+
const key = 1 + 2 * index;
|
|
33
|
+
env[`GIT_CONFIG_KEY_${key}`] = `credential.https://${binding.host}.helper`;
|
|
34
|
+
env[`GIT_CONFIG_VALUE_${key}`] = `!env -u GITHUB_TOKEN -u GH_TOKEN -u GITLAB_TOKEN ${helperPath}`;
|
|
35
|
+
env[`GIT_CONFIG_KEY_${key + 1}`] = `credential.https://${binding.host}.useHttpPath`;
|
|
36
|
+
env[`GIT_CONFIG_VALUE_${key + 1}`] = 'true';
|
|
37
|
+
if (binding.host === 'github.com') {
|
|
38
|
+
env['GH_TOKEN'] = binding.token;
|
|
39
|
+
env['GITHUB_TOKEN'] = binding.token;
|
|
40
|
+
}
|
|
41
|
+
else if (binding.kind === 'gitlab_token' && env['GITLAB_TOKEN'] === undefined) {
|
|
42
|
+
// glab/API env covers the first GitLab host in canonical order;
|
|
43
|
+
// Git's host-scoped helpers cover every binding.
|
|
44
|
+
env['GITLAB_TOKEN'] = binding.token;
|
|
45
|
+
env['GITLAB_HOST'] = binding.host;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
35
48
|
}
|
|
36
49
|
return env;
|
|
37
50
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { GitCredential } from './connections-client.js';
|
|
1
|
+
import type { GitCredential, GitCredentialKind, GitCredentialSet, PlatformCredential } from './connections-client.js';
|
|
2
2
|
import { type SessionInstance } from './session-instance.js';
|
|
3
|
-
type
|
|
3
|
+
type PlatformDelivery = Extract<GitCredential | GitCredentialSet, {
|
|
4
4
|
source: 'platform';
|
|
5
5
|
}>;
|
|
6
6
|
export interface GitCredentialSession extends SessionInstance {
|
|
@@ -10,13 +10,13 @@ export interface GitCredentialSession extends SessionInstance {
|
|
|
10
10
|
}
|
|
11
11
|
export interface AgentGitDelivery {
|
|
12
12
|
instance?: SessionInstance;
|
|
13
|
-
credential?: GitCredential;
|
|
13
|
+
credential?: GitCredential | GitCredentialSet;
|
|
14
14
|
session?: GitCredentialSession;
|
|
15
15
|
}
|
|
16
16
|
export declare function gitCredentialRoot(): string;
|
|
17
|
-
export declare function gitCredentialKind(credential: PlatformCredential):
|
|
18
|
-
export declare function writeGitTokenFile(dir: string, credential:
|
|
19
|
-
export declare function createGitCredentialSession(credential:
|
|
17
|
+
export declare function gitCredentialKind(credential: PlatformCredential): GitCredentialKind;
|
|
18
|
+
export declare function writeGitTokenFile(dir: string, credential: PlatformDelivery): void;
|
|
19
|
+
export declare function createGitCredentialSession(credential: PlatformDelivery, options: {
|
|
20
20
|
root?: string;
|
|
21
21
|
channelId: string;
|
|
22
22
|
threadId: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gitCredentialBindings } from './git-credential-bindings.js';
|
|
1
2
|
import { execFileSync } from 'node:child_process';
|
|
2
3
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
@@ -47,8 +48,13 @@ export function gitCredentialKind(credential) {
|
|
|
47
48
|
return credential.kind ?? (credential.expiresAt ? 'github_app' : 'github_token');
|
|
48
49
|
}
|
|
49
50
|
export function writeGitTokenFile(dir, credential) {
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
const entries = gitCredentialBindings(credential).map(binding => {
|
|
52
|
+
registerConfiguredToken(binding.token);
|
|
53
|
+
return { host: binding.host, username: binding.username, token: binding.token, expiresAt: binding.expiresAt, kind: gitCredentialKind(binding) };
|
|
54
|
+
});
|
|
55
|
+
// Channel sets use an ordered array; single brain credentials keep their
|
|
56
|
+
// existing file shape. The helper reads either format on every invocation.
|
|
57
|
+
atomicWrite(join(dir, 'git-token'), JSON.stringify('bindings' in credential ? entries : entries[0]));
|
|
52
58
|
}
|
|
53
59
|
export function createGitCredentialSession(credential, options) {
|
|
54
60
|
const root = options.root ?? gitCredentialRoot();
|
|
@@ -60,7 +66,9 @@ export function createGitCredentialSession(credential, options) {
|
|
|
60
66
|
const instance = options.instance ?? createSessionInstance();
|
|
61
67
|
writeSessionOwner(join(dir, 'owner.json'), instance);
|
|
62
68
|
writeGitTokenFile(dir, credential);
|
|
63
|
-
const metadata = { ...instance, ownerPid: process.pid, ownerIdentity, active: true, channelId: options.channelId, threadId: options.threadId,
|
|
69
|
+
const metadata = { ...instance, ownerPid: process.pid, ownerIdentity, active: true, channelId: options.channelId, threadId: options.threadId, ...('bindings' in credential
|
|
70
|
+
? { bindings: credential.bindings.map(binding => ({ connectionId: binding.connectionId, kind: gitCredentialKind(binding), host: binding.host })) }
|
|
71
|
+
: { connectionId: credential.connectionId, kind: gitCredentialKind(credential) }) };
|
|
64
72
|
atomicWrite(join(dir, 'session.json'), JSON.stringify(metadata));
|
|
65
73
|
const tasksFile = join(dir, 'bg-tasks');
|
|
66
74
|
writeFileSync(tasksFile, '', { mode: 0o600, flag: 'wx' });
|
|
@@ -187,7 +195,19 @@ function sweepSession(dir) {
|
|
|
187
195
|
reportSessionOwnerMismatch(expected, current);
|
|
188
196
|
const events = join(dir, 'git-credential-events');
|
|
189
197
|
if (metadata && matches && existsSync(events)) {
|
|
190
|
-
if (
|
|
198
|
+
if (metadata.bindings) {
|
|
199
|
+
const diagnostics = new Set(readFileSync(events, 'utf8').split('\n'));
|
|
200
|
+
const expiredHosts = new Set(metadata.expiredHosts);
|
|
201
|
+
for (const binding of metadata.bindings) {
|
|
202
|
+
if (!expiredHosts.has(binding.host) && diagnostics.has(`git_credential_expired kind=${binding.kind} host=${binding.host}`)) {
|
|
203
|
+
emitError('session', `git_credential_expired kind=${binding.kind} host=${binding.host}`, { channelId: metadata.channelId, context: { code: 'git_credential_expired', ...binding, threadId: metadata.threadId } });
|
|
204
|
+
expiredHosts.add(binding.host);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
metadata.expiredHosts = [...expiredHosts];
|
|
208
|
+
atomicWrite(path, JSON.stringify(metadata));
|
|
209
|
+
}
|
|
210
|
+
else if (!metadata.expiryReported) {
|
|
191
211
|
emitError('session', `git_credential_expired kind=${metadata.kind}`, { channelId: metadata.channelId, context: { code: 'git_credential_expired', kind: metadata.kind, connectionId: metadata.connectionId, threadId: metadata.threadId } });
|
|
192
212
|
metadata.expiryReported = true;
|
|
193
213
|
atomicWrite(path, JSON.stringify(metadata));
|
package/dist/inbox-watcher.d.ts
CHANGED
|
@@ -27,8 +27,8 @@ export interface InboxWatcherDeps {
|
|
|
27
27
|
* threads (see modules-teamvibe/hooks/slack-events.ts). Synthetic threadIds
|
|
28
28
|
* used elsewhere (`maintenance_<brainId>_<ts>`, and
|
|
29
29
|
* `scheduled:<scheduleId>:<ts>` from
|
|
30
|
-
* modules-teamvibe/src/functions/scheduler.ts
|
|
31
|
-
*
|
|
30
|
+
* modules-teamvibe/src/functions/scheduler.ts) carry no Slack destination;
|
|
31
|
+
* scheduler recovery needs the poller-owned routing record exclusively.
|
|
32
32
|
* `scheduled:*:*` has the same 3-colon-segment shape as a real Slack
|
|
33
33
|
* threadId, so it's rejected explicitly rather than by segment count alone
|
|
34
34
|
* — otherwise a leftover unprocessed file in a stale scheduled-session inbox
|
package/dist/inbox-watcher.js
CHANGED
|
@@ -38,8 +38,8 @@ function defaultDeps() {
|
|
|
38
38
|
* threads (see modules-teamvibe/hooks/slack-events.ts). Synthetic threadIds
|
|
39
39
|
* used elsewhere (`maintenance_<brainId>_<ts>`, and
|
|
40
40
|
* `scheduled:<scheduleId>:<ts>` from
|
|
41
|
-
* modules-teamvibe/src/functions/scheduler.ts
|
|
42
|
-
*
|
|
41
|
+
* modules-teamvibe/src/functions/scheduler.ts) carry no Slack destination;
|
|
42
|
+
* scheduler recovery needs the poller-owned routing record exclusively.
|
|
43
43
|
* `scheduled:*:*` has the same 3-colon-segment shape as a real Slack
|
|
44
44
|
* threadId, so it's rejected explicitly rather than by segment count alone
|
|
45
45
|
* — otherwise a leftover unprocessed file in a stale scheduled-session inbox
|
|
@@ -65,20 +65,16 @@ function platformRouting(info) {
|
|
|
65
65
|
brain: { brainId: info.brainId, gitRepoUrl: info.gitRepoUrl, branch: info.branch },
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
|
-
function buildSyntheticMessage(info, threadId,
|
|
68
|
+
function buildSyntheticMessage(info, threadId, slack, source = 'slack') {
|
|
69
69
|
return {
|
|
70
70
|
id: `inbox_watch_${threadId}`,
|
|
71
|
-
source
|
|
71
|
+
source,
|
|
72
72
|
thread_id: threadId,
|
|
73
73
|
sender: { id: 'system', name: 'Background Task' },
|
|
74
74
|
text: '',
|
|
75
75
|
attachments: [],
|
|
76
76
|
response_context: {
|
|
77
|
-
slack
|
|
78
|
-
channel: parsed.channel,
|
|
79
|
-
thread_ts: parsed.threadTs,
|
|
80
|
-
message_ts: parsed.threadTs,
|
|
81
|
-
},
|
|
77
|
+
slack,
|
|
82
78
|
},
|
|
83
79
|
type: 'message',
|
|
84
80
|
teamvibe: platformRouting(info),
|
|
@@ -174,9 +170,13 @@ export async function checkInboxForNewSessions(deps = defaultDeps()) {
|
|
|
174
170
|
continue;
|
|
175
171
|
const durable = readInboxContext(brainPath, threadId);
|
|
176
172
|
const parsed = parseSlackThreadId(threadId);
|
|
173
|
+
const scheduled = threadId.startsWith('scheduled:');
|
|
177
174
|
// Context or poller-owned per-thread routing selects a channel in the
|
|
178
175
|
// full map. Never infer ownership from a directory's current channels.
|
|
179
|
-
const
|
|
176
|
+
const routingRecord = readThreadRouting(threadId);
|
|
177
|
+
// Scheduler routing is trusted poller state. Brain-writable envelopes and
|
|
178
|
+
// sidecars cannot select either its platform channel or Slack destination.
|
|
179
|
+
const storedRoute = scheduled ? routingRecord : readInboxChannelContext(brainPath, threadId) ?? routingRecord;
|
|
180
180
|
const route = storedRoute
|
|
181
181
|
? routes.find(({ info }) => info.workspaceId === storedRoute.workspaceId && info.channelId === storedRoute.channelId)
|
|
182
182
|
: undefined;
|
|
@@ -186,9 +186,16 @@ export async function checkInboxForNewSessions(deps = defaultDeps()) {
|
|
|
186
186
|
}
|
|
187
187
|
const info = route.info;
|
|
188
188
|
const directory = route.directory;
|
|
189
|
-
|
|
189
|
+
// Synthetic IDs need the original Slack destination captured at spawn.
|
|
190
|
+
// Match its platform scope before using it alongside the current route.
|
|
191
|
+
const slack = parsed
|
|
192
|
+
? { channel: parsed.channel, thread_ts: parsed.threadTs, message_ts: parsed.threadTs }
|
|
193
|
+
: routingRecord?.workspaceId === info.workspaceId && routingRecord.channelId === info.channelId
|
|
194
|
+
? routingRecord.responseContext?.slack
|
|
195
|
+
: undefined;
|
|
196
|
+
const current = slack ? buildSyntheticMessage(info, threadId, slack, scheduled ? routingRecord?.source ?? 'cron' : 'slack') : undefined;
|
|
190
197
|
// Stored context supplies content, never the selected directory identity.
|
|
191
|
-
const message = durable
|
|
198
|
+
const message = scheduled ? current : durable
|
|
192
199
|
? { ...durable, teamvibe: platformRouting(info) }
|
|
193
200
|
: current;
|
|
194
201
|
if (!message) {
|
|
@@ -324,7 +331,7 @@ function quarantineRetainedThread(deps, brainPath, threadId, durable, expired =
|
|
|
324
331
|
const info = [...deps.getTrackedBrains().values()].find(info => info.botId === threadId.split(':')[0] && (!durable || info.workspaceId === durable.teamvibe.workspaceId));
|
|
325
332
|
if (!info)
|
|
326
333
|
return true;
|
|
327
|
-
void deps.sendSlackMessage(buildSyntheticMessage(info, threadId, parsed), 'A retained background result could not be routed to a current channel. It has been saved for operator review; please contact your poller operator.')
|
|
334
|
+
void deps.sendSlackMessage(buildSyntheticMessage(info, threadId, { channel: parsed.channel, thread_ts: parsed.threadTs, message_ts: parsed.threadTs }), 'A retained background result could not be routed to a current channel. It has been saved for operator review; please contact your poller operator.')
|
|
328
335
|
.catch(() => logger.error('[InboxWatcher] Could not send unroutable inbox notice'));
|
|
329
336
|
return true;
|
|
330
337
|
}
|
package/dist/poller.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type BrainDirectory } from './brain-directory.js';
|
|
2
2
|
import { logger } from './logger.js';
|
|
3
|
+
import { type ReceivedMessage } from './sqs-poller.js';
|
|
3
4
|
import { type SpawnResult } from './claude-spawner.js';
|
|
4
5
|
import { type KilledByPoller } from './classify-failed-iteration.js';
|
|
5
6
|
import type { TeamVibeQueueMessage } from './types.js';
|
|
@@ -31,11 +32,11 @@ export declare function isPollerInitiatedFailure(result: {
|
|
|
31
32
|
killedByPoller?: KilledByPoller;
|
|
32
33
|
}): boolean;
|
|
33
34
|
export declare function describeFailureReason(killedByPoller: KilledByPoller | undefined, exitCode: number | null): string;
|
|
34
|
-
export
|
|
35
|
+
export { END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL } from './end-of-turn-sentinel.js';
|
|
35
36
|
export declare function endOfTurnFallbackText(hasSlackContext: boolean, result: Pick<SpawnResult, 'success' | 'sawSendMessageCall' | 'lastPlainTextContent'>): string | undefined;
|
|
36
|
-
export declare function runInboxLoop(threadId: string, selectedDirectory: BrainDirectory | string, queueMessage: TeamVibeQueueMessage, sessionLog: ReturnType<typeof logger.createSession
|
|
37
|
+
export declare function runInboxLoop(threadId: string, selectedDirectory: BrainDirectory | string, queueMessage: TeamVibeQueueMessage, sessionLog: ReturnType<typeof logger.createSession>, executionOrigin?: 'queue' | 'recovery'): Promise<void>;
|
|
38
|
+
export declare function processMessage(received: ReceivedMessage): Promise<void>;
|
|
37
39
|
export declare function pollLoop(): Promise<void>;
|
|
38
40
|
export declare function __setSleepForTest(impl: (ms: number) => Promise<void>): void;
|
|
39
41
|
export declare function __setShuttingDownForTest(value: boolean): void;
|
|
40
42
|
export declare function startPoller(): Promise<void>;
|
|
41
|
-
export {};
|
package/dist/poller.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL } from './end-of-turn-sentinel.js';
|
|
1
2
|
import { startGitCredentialJanitor, registeredGitCredentialTasks } from './git-credential-session.js';
|
|
2
3
|
import { isUsageProbe, handleUsageProbe } from './usage-probe-job.js';
|
|
3
4
|
import { fetchSubscriptionPool } from './subscription-pool-client.js';
|
|
@@ -26,8 +27,8 @@ import { registerBrain, startMaintenanceLoop, stopMaintenanceLoop } from './main
|
|
|
26
27
|
import { bootstrapTrackedBrains } from './brains-bootstrap.js';
|
|
27
28
|
import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent, emitError } from './event-queue.js';
|
|
28
29
|
import { startMetricsCron, stopMetricsCron } from './metrics/scheduler.js';
|
|
29
|
-
import { inboxQueueMessageIds, hasInboxMessageAfter, writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed, requeueMessage, saveInboxContext, saveInboxRuntime, readInboxRuntime, clearInboxRuntime } from './inbox-manager.js';
|
|
30
|
-
import { saveThreadRouting } from './thread-routing.js';
|
|
30
|
+
import { inboxQueueMessageIds, hasInboxMessageAfter, writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed, requeueMessage, saveInboxContext, saveInboxRuntime, readInboxRuntime, clearInboxRuntime, quarantineInbox } from './inbox-manager.js';
|
|
31
|
+
import { readThreadRouting, saveThreadRouting } from './thread-routing.js';
|
|
31
32
|
import { startInboxWatcher, stopInboxWatcher } from './inbox-watcher.js';
|
|
32
33
|
import { Cron } from 'croner';
|
|
33
34
|
import { isControlMessage, handleControlMessage } from './control-handler.js';
|
|
@@ -232,9 +233,9 @@ export function describeFailureReason(killedByPoller, exitCode) {
|
|
|
232
233
|
// string match, not a content heuristic — anything else (including real
|
|
233
234
|
// content an agent forgot to send via send_message) still delivers as
|
|
234
235
|
// before, so this can't silently drop a forgotten reply.
|
|
235
|
-
export
|
|
236
|
+
export { END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL } from './end-of-turn-sentinel.js';
|
|
236
237
|
// tv#358 (revised): the sentinel agents emit and the guard matches is
|
|
237
|
-
// unchanged — still the exact ALL_CAPS literal
|
|
238
|
+
// unchanged — still the exact shared ALL_CAPS literal, still an exact-match
|
|
238
239
|
// suppression. What changes is purely cosmetic: poller-brain#428's leak
|
|
239
240
|
// failure mode (a sentence written before the sentinel breaks the exact
|
|
240
241
|
// match, so the whole thing delivers as-is) showed the raw literal reads as
|
|
@@ -251,9 +252,23 @@ export function endOfTurnFallbackText(hasSlackContext, result) {
|
|
|
251
252
|
}
|
|
252
253
|
return text.split(END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL).join(END_OF_TURN_SENTINEL_DISPLAY);
|
|
253
254
|
}
|
|
254
|
-
export async function runInboxLoop(threadId, selectedDirectory, queueMessage, sessionLog) {
|
|
255
|
+
export async function runInboxLoop(threadId, selectedDirectory, queueMessage, sessionLog, executionOrigin = 'recovery') {
|
|
255
256
|
const directory = executionDirectory(selectedDirectory);
|
|
256
257
|
const kbPath = directory.path;
|
|
258
|
+
const scheduled = threadId.startsWith('scheduled:');
|
|
259
|
+
// A trusted queue job may legitimately have no destination. Only retained
|
|
260
|
+
// inbox recovery requires a captured destination before executing its drop.
|
|
261
|
+
if (scheduled && executionOrigin === 'recovery') {
|
|
262
|
+
const routing = readThreadRouting(threadId);
|
|
263
|
+
if (!routing?.responseContext?.slack) {
|
|
264
|
+
quarantineInbox(kbPath, threadId);
|
|
265
|
+
const detail = 'Retained scheduler inbox has no trusted Slack destination; saved in unroutable/ for operator review';
|
|
266
|
+
sessionLog.error(`[Loop] ${detail}: ${threadId}`);
|
|
267
|
+
emitError('session', detail, { context: { threadId, reason: 'unroutable_inbox' } });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
queueMessage = { ...queueMessage, source: routing.source ?? 'cron', response_context: routing.responseContext };
|
|
271
|
+
}
|
|
257
272
|
if (isQuotaPaused() && !getPollerId())
|
|
258
273
|
return;
|
|
259
274
|
const initialPool = await fetchRuntimePool(queueMessage);
|
|
@@ -275,6 +290,7 @@ export async function runInboxLoop(threadId, selectedDirectory, queueMessage, se
|
|
|
275
290
|
activeLoops.add(threadId);
|
|
276
291
|
let iteration = 0;
|
|
277
292
|
const failureCounts = new Map();
|
|
293
|
+
const loggedRoutingDrops = new Set();
|
|
278
294
|
try {
|
|
279
295
|
let lockResult = await acquireSessionLock(threadId, kbPath);
|
|
280
296
|
if (!lockResult.success) {
|
|
@@ -430,6 +446,10 @@ export async function runInboxLoop(threadId, selectedDirectory, queueMessage, se
|
|
|
430
446
|
try {
|
|
431
447
|
// Reconstruct queue message from stored inbox data
|
|
432
448
|
const storedData = JSON.parse(message.content);
|
|
449
|
+
if (scheduled && 'response_context' in storedData && !loggedRoutingDrops.has(message.filename)) {
|
|
450
|
+
logger.debug(`[Loop] Ignoring inbox response_context for scheduler routing: ${threadId} (${message.filename})`);
|
|
451
|
+
loggedRoutingDrops.add(message.filename);
|
|
452
|
+
}
|
|
433
453
|
const storedScope = storedData.teamvibe;
|
|
434
454
|
const scopeMismatch = storedScope && (storedScope.workspaceId !== queueMessage.teamvibe.workspaceId ||
|
|
435
455
|
storedScope.channelId !== queueMessage.teamvibe.channelId ||
|
|
@@ -444,7 +464,8 @@ export async function runInboxLoop(threadId, selectedDirectory, queueMessage, se
|
|
|
444
464
|
// inbox data. This guard applies to both direct and pool execution.
|
|
445
465
|
thread_id: threadId,
|
|
446
466
|
teamvibe: { ...storedScope, ...queueMessage.teamvibe, brain: queueMessage.teamvibe.brain },
|
|
447
|
-
...(scopeMismatch ? { response_context: queueMessage.response_context } : {}),
|
|
467
|
+
...(scopeMismatch || scheduled ? { response_context: queueMessage.response_context } : {}),
|
|
468
|
+
...(scheduled ? { source: queueMessage.source } : {}),
|
|
448
469
|
};
|
|
449
470
|
// Legacy delivery uses the original routing, just as on main.
|
|
450
471
|
let result;
|
|
@@ -877,7 +898,7 @@ export async function runInboxLoop(threadId, selectedDirectory, queueMessage, se
|
|
|
877
898
|
activeLocks.delete(threadId);
|
|
878
899
|
}
|
|
879
900
|
}
|
|
880
|
-
async function processMessage(received) {
|
|
901
|
+
export async function processMessage(received) {
|
|
881
902
|
const { queueMessage, receiptHandle, messageId } = received;
|
|
882
903
|
const logSessionId = messageId.slice(0, 8);
|
|
883
904
|
const threadId = queueMessage.thread_id;
|
|
@@ -922,6 +943,20 @@ async function processMessage(received) {
|
|
|
922
943
|
// Register brain so the maintenance cron knows about it.
|
|
923
944
|
registerBrain(queueMessage, directory);
|
|
924
945
|
const kbPath = directory.path;
|
|
946
|
+
// This is the trusted queue boundary, before creating any brain-writable
|
|
947
|
+
// inbox copy. Recovery may update assignment/bookkeeping, never destination
|
|
948
|
+
// or source. Synthetic scheduler IDs retain their independent lock identity.
|
|
949
|
+
if (threadId.startsWith('scheduled:')) {
|
|
950
|
+
saveThreadRouting(threadId, {
|
|
951
|
+
workspaceId: queueMessage.teamvibe.workspaceId,
|
|
952
|
+
channelId: queueMessage.teamvibe.channelId,
|
|
953
|
+
brainId: queueMessage.teamvibe.brain?.brainId,
|
|
954
|
+
directory: kbPath,
|
|
955
|
+
source: queueMessage.source,
|
|
956
|
+
responseContext: queueMessage.response_context.slack?.channel
|
|
957
|
+
? { slack: { ...queueMessage.response_context.slack } } : undefined,
|
|
958
|
+
});
|
|
959
|
+
}
|
|
925
960
|
// Write raw message to inbox (spawnClaudeCode handles prompt formatting)
|
|
926
961
|
const { botToken: _inboxToken, ...inboxTeamvibe } = queueMessage.teamvibe;
|
|
927
962
|
writeMessage(kbPath, threadId, JSON.stringify({ ...queueMessage, executionOrigin: receiptHandle ? 'queue' : 'maintenance', teamvibe: inboxTeamvibe }));
|
|
@@ -933,7 +968,7 @@ async function processMessage(received) {
|
|
|
933
968
|
}
|
|
934
969
|
processingMessages.add(messageId);
|
|
935
970
|
try {
|
|
936
|
-
await runInboxLoop(threadId, directory, queueMessage, sessionLog);
|
|
971
|
+
await runInboxLoop(threadId, directory, queueMessage, sessionLog, 'queue');
|
|
937
972
|
}
|
|
938
973
|
catch (error) {
|
|
939
974
|
sessionLog.error(`Error in inbox loop: ${error instanceof Error ? error.message : error}`);
|
package/dist/redact-text.js
CHANGED
|
@@ -19,7 +19,7 @@ export function redactStartupText(text, configuredTokens = []) {
|
|
|
19
19
|
let safe = text
|
|
20
20
|
.replace(/https?:\/\/[^\s/@]+@/gi, 'https://[REDACTED]@')
|
|
21
21
|
.replace(/\b(?:Authorization\s*:\s*)?Bearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]')
|
|
22
|
-
.replace(/\
|
|
22
|
+
.replace(/\b(x-access-token|oauth2)\s*:\s*[^\s/@,;]+/gi, '$1:[REDACTED]')
|
|
23
23
|
// Cover both Claude's token name and the opt-in subprocess alias in
|
|
24
24
|
// shell assignments and JSON env diagnostics, even without a token list.
|
|
25
25
|
.replace(/(\b(?:CLAUDE_CODE_OAUTH_TOKEN|TEAMVIBE_CLAUDE_OAUTH_TOKEN)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'[^']*'|[^\s,;]+)/gi, '$1[REDACTED]');
|
package/dist/slack-client.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface TypingIndicator {
|
|
|
17
17
|
/** Stop the typing indicator and clear the status */
|
|
18
18
|
stop: () => void;
|
|
19
19
|
}
|
|
20
|
-
export declare function startTypingIndicator(msg: TeamVibeQueueMessage, statusText?: string): TypingIndicator
|
|
20
|
+
export declare function startTypingIndicator(msg: TeamVibeQueueMessage, statusText?: string): TypingIndicator & {
|
|
21
|
+
__flushForTest: () => Promise<void>;
|
|
22
|
+
};
|
|
21
23
|
export declare function removeReaction(msg: TeamVibeQueueMessage, emoji: string): Promise<void>;
|
|
22
24
|
export {};
|
package/dist/slack-client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { WebClient } from '@slack/web-api';
|
|
1
|
+
import { LogLevel, WebClient } from '@slack/web-api';
|
|
2
2
|
import { logger } from './logger.js';
|
|
3
3
|
import { emitError } from './event-queue.js';
|
|
4
4
|
import { config } from './config.js';
|
|
@@ -6,6 +6,20 @@ import { config } from './config.js';
|
|
|
6
6
|
const userCache = new Map();
|
|
7
7
|
// Per-token Slack client cache
|
|
8
8
|
const slackClients = new Map();
|
|
9
|
+
const slackStatusClients = new Map();
|
|
10
|
+
// tv#589 review: the SDK's own logger prints raw `response_metadata.messages`,
|
|
11
|
+
// which can echo the status text (agent narration) on an API error. The
|
|
12
|
+
// status client therefore gets a silent logger; application code owns the
|
|
13
|
+
// redacted warnings in requestThreadStatus below.
|
|
14
|
+
const silentSlackLogger = {
|
|
15
|
+
debug: () => { },
|
|
16
|
+
info: () => { },
|
|
17
|
+
warn: () => { },
|
|
18
|
+
error: () => { },
|
|
19
|
+
setLevel: () => { },
|
|
20
|
+
getLevel: () => LogLevel.ERROR,
|
|
21
|
+
setName: () => { },
|
|
22
|
+
};
|
|
9
23
|
// Slack: "must be less than 51 characters [json-pointer:/loading_messages/0]"
|
|
10
24
|
const SLACK_LOADING_MESSAGE_MAX = 50;
|
|
11
25
|
function getSlackClient(botToken) {
|
|
@@ -16,6 +30,21 @@ function getSlackClient(botToken) {
|
|
|
16
30
|
}
|
|
17
31
|
return client;
|
|
18
32
|
}
|
|
33
|
+
function getSlackStatusClient(botToken) {
|
|
34
|
+
let client = slackStatusClients.get(botToken);
|
|
35
|
+
if (!client) {
|
|
36
|
+
client = new WebClient(botToken, {
|
|
37
|
+
// The SDK passes timeout to Axios, which aborts the underlying HTTP request.
|
|
38
|
+
timeout: config.TYPING_STATUS_REQUEST_TIMEOUT_MS,
|
|
39
|
+
// Let the indicator recover immediately and own the single clear retry.
|
|
40
|
+
retryConfig: { retries: 0 },
|
|
41
|
+
rejectRateLimitedCalls: true,
|
|
42
|
+
logger: silentSlackLogger, // Never let the SDK echo status text; warnings are redacted below.
|
|
43
|
+
});
|
|
44
|
+
slackStatusClients.set(botToken, client);
|
|
45
|
+
}
|
|
46
|
+
return client;
|
|
47
|
+
}
|
|
19
48
|
export async function getUserInfo(botToken, userId) {
|
|
20
49
|
const cached = userCache.get(userId);
|
|
21
50
|
if (cached)
|
|
@@ -111,16 +140,29 @@ export async function addReaction(msg, emoji) {
|
|
|
111
140
|
}
|
|
112
141
|
}
|
|
113
142
|
export async function setThreadStatus(msg, status) {
|
|
143
|
+
// Standalone callers retain best-effort delivery; indicators handle retries.
|
|
144
|
+
await requestThreadStatus(msg, status).catch(() => { });
|
|
145
|
+
}
|
|
146
|
+
async function requestThreadStatus(msg, status, onTimeout = () => logger.warn('Slack thread status request timed out')) {
|
|
114
147
|
if (!msg.response_context.slack)
|
|
115
148
|
return;
|
|
116
149
|
const { channel, thread_ts } = msg.response_context.slack;
|
|
117
|
-
const slack =
|
|
150
|
+
const slack = getSlackStatusClient(msg.teamvibe.botToken);
|
|
118
151
|
const safeStatus = neutralizeSlackMarkup(status);
|
|
119
152
|
const payload = {
|
|
120
153
|
channel_id: channel,
|
|
121
154
|
thread_ts,
|
|
122
155
|
status: safeStatus,
|
|
123
156
|
};
|
|
157
|
+
const reportFailure = (error) => {
|
|
158
|
+
// WebClient wraps Axios errors in a WebAPIRequestError with the original attached.
|
|
159
|
+
const code = error?.original?.code ?? error?.code;
|
|
160
|
+
if (code === 'ECONNABORTED' || code === 'ETIMEDOUT')
|
|
161
|
+
onTimeout();
|
|
162
|
+
else
|
|
163
|
+
logger.warn('Failed to set thread status');
|
|
164
|
+
emitError('slack', 'Failed to set thread status', { severity: 'warn' });
|
|
165
|
+
};
|
|
124
166
|
try {
|
|
125
167
|
await slack.apiCall('assistant.threads.setStatus', {
|
|
126
168
|
...payload,
|
|
@@ -137,14 +179,14 @@ export async function setThreadStatus(msg, status) {
|
|
|
137
179
|
await slack.apiCall('assistant.threads.setStatus', payload);
|
|
138
180
|
return;
|
|
139
181
|
}
|
|
140
|
-
catch {
|
|
141
|
-
|
|
142
|
-
|
|
182
|
+
catch (retryError) {
|
|
183
|
+
reportFailure(retryError);
|
|
184
|
+
throw retryError;
|
|
143
185
|
}
|
|
144
186
|
}
|
|
145
187
|
// API errors may echo request fields; status can contain private narration.
|
|
146
|
-
|
|
147
|
-
|
|
188
|
+
reportFailure(error);
|
|
189
|
+
throw error;
|
|
148
190
|
}
|
|
149
191
|
}
|
|
150
192
|
function truncateLoadingMessage(text) {
|
|
@@ -176,9 +218,36 @@ export function startTypingIndicator(msg, statusText = 'Thinking...') {
|
|
|
176
218
|
else
|
|
177
219
|
logger.info(`[Typing] status -> ${source} (${status.length} chars)`);
|
|
178
220
|
};
|
|
221
|
+
// Serialize all writes so an older request cannot land after a newer status.
|
|
222
|
+
let statusChain = Promise.resolve();
|
|
223
|
+
let pendingRequests = 0;
|
|
224
|
+
let timeoutWarned = false;
|
|
225
|
+
const warnTimeout = () => {
|
|
226
|
+
if (timeoutWarned)
|
|
227
|
+
return;
|
|
228
|
+
timeoutWarned = true;
|
|
229
|
+
logger.warn('Slack thread status request timed out');
|
|
230
|
+
};
|
|
231
|
+
const enqueueStatus = (text, retryClear = false) => {
|
|
232
|
+
pendingRequests++;
|
|
233
|
+
const request = statusChain.then(async () => {
|
|
234
|
+
try {
|
|
235
|
+
await requestThreadStatus(msg, text, warnTimeout);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (!retryClear)
|
|
239
|
+
throw error;
|
|
240
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
241
|
+
await requestThreadStatus(msg, text, warnTimeout);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
// Recover the chain so a failed write never blocks later writes or the clear.
|
|
245
|
+
statusChain = request.catch(() => { }).finally(() => { pendingRequests--; });
|
|
246
|
+
return request;
|
|
247
|
+
};
|
|
179
248
|
// Set initial status
|
|
180
249
|
logStatus(currentStatus, currentSource);
|
|
181
|
-
|
|
250
|
+
void enqueueStatus(currentStatus).catch(() => { });
|
|
182
251
|
const cancelHold = () => {
|
|
183
252
|
if (holdTimer !== undefined)
|
|
184
253
|
clearTimeout(holdTimer);
|
|
@@ -190,17 +259,19 @@ export function startTypingIndicator(msg, statusText = 'Thinking...') {
|
|
|
190
259
|
currentStatus = text;
|
|
191
260
|
currentSource = text ? source : 'default';
|
|
192
261
|
if (text && source === 'comment')
|
|
193
|
-
commentShownAt = Date.now();
|
|
262
|
+
commentShownAt = Date.now(); // Pre-existing enqueue-time hold, before successful display (#589 review P2-2).
|
|
194
263
|
if (changed) {
|
|
195
264
|
logStatus(text, source);
|
|
196
|
-
|
|
265
|
+
void enqueueStatus(text).catch(() => { });
|
|
197
266
|
}
|
|
198
267
|
};
|
|
199
268
|
// Keepalive every 3 seconds (Slack clears after ~2 min timeout)
|
|
200
269
|
let failures = 0;
|
|
201
270
|
const interval = setInterval(async () => {
|
|
271
|
+
if (stopped || pendingRequests > 0)
|
|
272
|
+
return;
|
|
202
273
|
try {
|
|
203
|
-
await
|
|
274
|
+
await enqueueStatus(currentStatus);
|
|
204
275
|
failures = 0;
|
|
205
276
|
}
|
|
206
277
|
catch {
|
|
@@ -211,6 +282,7 @@ export function startTypingIndicator(msg, statusText = 'Thinking...') {
|
|
|
211
282
|
}
|
|
212
283
|
}, 3000);
|
|
213
284
|
return {
|
|
285
|
+
__flushForTest: () => statusChain,
|
|
214
286
|
updateStatus: (newStatus, source = 'comment') => {
|
|
215
287
|
if (stopped)
|
|
216
288
|
return;
|
|
@@ -244,7 +316,7 @@ export function startTypingIndicator(msg, statusText = 'Thinking...') {
|
|
|
244
316
|
clearInterval(interval);
|
|
245
317
|
if (currentStatus)
|
|
246
318
|
logStatus('', 'default');
|
|
247
|
-
|
|
319
|
+
void enqueueStatus('', true).catch(() => { });
|
|
248
320
|
},
|
|
249
321
|
};
|
|
250
322
|
}
|
package/dist/thread-routing.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TeamVibeQueueMessage } from './types.js';
|
|
1
2
|
export interface ThreadRouting {
|
|
2
3
|
workspaceId: string;
|
|
3
4
|
channelId: string;
|
|
@@ -6,6 +7,12 @@ export interface ThreadRouting {
|
|
|
6
7
|
directory: string;
|
|
7
8
|
threadId?: string;
|
|
8
9
|
processedDropIds?: string[];
|
|
10
|
+
/** Original queue source; recovery must preserve its worktree isolation policy. */
|
|
11
|
+
source?: TeamVibeQueueMessage['source'];
|
|
12
|
+
/** Captured only from trusted queue input, never inbox drops or context files. */
|
|
13
|
+
responseContext?: {
|
|
14
|
+
slack: NonNullable<TeamVibeQueueMessage['response_context']['slack']>;
|
|
15
|
+
};
|
|
9
16
|
}
|
|
10
17
|
/** Poller state survives inbox pruning and process restarts; contains no credentials. */
|
|
11
18
|
export declare function saveThreadRouting(threadId: string, routing: ThreadRouting): void;
|
package/dist/thread-routing.js
CHANGED
|
@@ -25,6 +25,13 @@ export function readThreadRouting(threadId) {
|
|
|
25
25
|
return undefined;
|
|
26
26
|
if (routing.processedDropIds !== undefined && (!Array.isArray(routing.processedDropIds) || !routing.processedDropIds.every((id) => typeof id === 'string')))
|
|
27
27
|
return undefined;
|
|
28
|
+
if (routing.source !== undefined && !['slack', 'email', 'cron', 'maintenance', 'webhook'].includes(routing.source))
|
|
29
|
+
return undefined;
|
|
30
|
+
if (routing.responseContext !== undefined) {
|
|
31
|
+
const slack = routing.responseContext?.slack;
|
|
32
|
+
if (!slack || typeof slack.channel !== 'string' || !slack.channel || typeof slack.thread_ts !== 'string' || typeof slack.message_ts !== 'string')
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
28
35
|
return routing;
|
|
29
36
|
}
|
|
30
37
|
catch {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teamvibe/poller",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.9-rc.1",
|
|
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.9-rc.1",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@aws-sdk/client-dynamodb": "3.1108.0",
|
|
12
12
|
"@aws-sdk/client-sqs": "3.1108.0",
|