@teamvibe/poller 0.8.8 → 0.8.9-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.source === 'none')
93
- value = { source: 'none', ...(body.legacyAllowed === true ? { legacyAllowed: true } : {}) };
94
- else if (body.source === 'platform' && body.host === 'github.com' && body.username === 'x-access-token' &&
95
- typeof body.token === 'string' && body.token && typeof body.connectionId === 'string' &&
96
- typeof body.credentialVersion === 'string' && typeof body.bindingVersion === 'number') {
97
- value = body;
98
- registerConfiguredToken(body.token);
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 validUntil = value.source === 'platform' && value.expiresAt
103
- ? Date.parse(value.expiresAt) - 5 * 60_000 : this.now() + 10 * 60_000;
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;
@@ -0,0 +1,2 @@
1
+ /** Claude's end-of-turn marker when no further visible reply is needed. */
2
+ export declare const END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL = "NO_FURTHER_RESPONSE_NEEDED";
@@ -0,0 +1,2 @@
1
+ /** Claude's end-of-turn marker when no further visible reply is needed. */
2
+ export const END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL = 'NO_FURTHER_RESPONSE_NEEDED';
@@ -0,0 +1,2 @@
1
+ import type { GitCredential, GitCredentialSet, PlatformCredential } from './connections-client.js';
2
+ export declare function gitCredentialBindings(credential: GitCredential | GitCredentialSet): PlatformCredential[];
@@ -0,0 +1,4 @@
1
+ // Shared single-consumer adapter; preserves the brain credential contract.
2
+ export function gitCredentialBindings(credential) {
3
+ return credential.source === 'none' ? [] : 'bindings' in credential ? credential.bindings : [credential];
4
+ }
@@ -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
- env['GIT_CONFIG_COUNT'] = credential.source === 'platform' ? '3' : '1';
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
- env['GIT_CONFIG_VALUE_1'] = `!env -u GITHUB_TOKEN -u GH_TOKEN -u GITLAB_TOKEN ${helperPath}`;
31
- env['GIT_CONFIG_KEY_2'] = `credential.https://${credential.host}.useHttpPath`;
32
- env['GIT_CONFIG_VALUE_2'] = 'true';
33
- env['GH_TOKEN'] = credential.token;
34
- env['GITHUB_TOKEN'] = credential.token;
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 PlatformCredential = Extract<GitCredential, {
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): 'github_app' | 'github_token';
18
- export declare function writeGitTokenFile(dir: string, credential: PlatformCredential): void;
19
- export declare function createGitCredentialSession(credential: PlatformCredential, options: {
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
- registerConfiguredToken(credential.token);
51
- atomicWrite(join(dir, 'git-token'), JSON.stringify({ host: credential.host, token: credential.token, expiresAt: credential.expiresAt, kind: gitCredentialKind(credential) }));
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, connectionId: credential.connectionId, kind: gitCredentialKind(credential) };
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 (!metadata.expiryReported) {
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));
@@ -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:86) never sit idle waiting on
31
- * a background-task drop, so they're skipped rather than guessed at.
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
@@ -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:86) never sit idle waiting on
42
- * a background-task drop, so they're skipped rather than guessed at.
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, parsed) {
68
+ function buildSyntheticMessage(info, threadId, slack, source = 'slack') {
69
69
  return {
70
70
  id: `inbox_watch_${threadId}`,
71
- source: 'slack',
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 storedRoute = readInboxChannelContext(brainPath, threadId) ?? readThreadRouting(threadId);
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
- const current = parsed ? buildSyntheticMessage(info, threadId, parsed) : undefined;
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 declare const END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL = "NO_FURTHER_RESPONSE_NEEDED";
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>): Promise<void>;
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 const END_OF_TURN_NO_FURTHER_RESPONSE_SENTINEL = 'NO_FURTHER_RESPONSE_NEEDED';
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 above, still an exact-match
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}`);
@@ -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(/\bx-access-token\s*:\s*[^\s/@,;]+/gi, 'x-access-token:[REDACTED]')
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]');
@@ -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 {};