@teamvibe/poller 0.8.7 → 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 +4 -2
- package/dist/claude-spawner.js +50 -16
- 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 +63 -11
- package/dist/redact-text.js +1 -1
- package/dist/slack-client.d.ts +3 -1
- package/dist/slack-client.js +84 -12
- package/dist/subscription-runtime.d.ts +2 -0
- package/dist/subscription-runtime.js +6 -2
- package/dist/subscription-selection.d.ts +21 -2
- package/dist/subscription-selection.js +91 -14
- package/dist/subscription-usage-store.d.ts +6 -1
- package/dist/subscription-usage-store.js +246 -13
- package/dist/thread-routing.d.ts +7 -0
- package/dist/thread-routing.js +7 -0
- package/dist/types.d.ts +2 -0
- package/dist/usage-limit-messages.d.ts +3 -0
- package/dist/usage-limit-messages.js +22 -0
- package/dist/usage-probe-job.js +31 -3
- 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 {
|
|
@@ -28,6 +28,8 @@ export interface SpawnResult {
|
|
|
28
28
|
poolAttempts?: readonly PoolAttempt[];
|
|
29
29
|
quota?: ClaudeQuotaFailure;
|
|
30
30
|
rateLimitInfo?: ClaudeRateLimitInfo;
|
|
31
|
+
/** Rejections anywhere in this attempt survive a later allowed stream event. */
|
|
32
|
+
rateLimitRejections?: readonly ClaudeRateLimitInfo[];
|
|
31
33
|
/** Actual ID passed to this CLI attempt, including a failed first turn. */
|
|
32
34
|
attemptedSessionId?: string;
|
|
33
35
|
success: boolean;
|
|
@@ -177,7 +179,7 @@ export interface BuildSpawnEnvParams {
|
|
|
177
179
|
inboxThreadId: string;
|
|
178
180
|
brainPath: string;
|
|
179
181
|
subscriptionAccount?: SubscriptionAccount;
|
|
180
|
-
gitCredential?: GitCredential;
|
|
182
|
+
gitCredential?: GitCredential | GitCredentialSet;
|
|
181
183
|
gitTokenFile?: string;
|
|
182
184
|
}
|
|
183
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';
|
|
@@ -20,7 +21,7 @@ import { fetchSecretsForSpawn, buildSecretsReminder } from './secrets-client.js'
|
|
|
20
21
|
import { fetchConfigForSpawn } from './config-client.js';
|
|
21
22
|
import { parseClaudeRateLimitInfo } from './claude-rate-limit.js';
|
|
22
23
|
import { parseClaudeQuotaFailure } from './claude-quota.js';
|
|
23
|
-
import { abortLocalProbe, accountPin, preparePool, fetchRuntimePool, validateAccount, poolDeferredError, PoolDeferredError } from './subscription-runtime.js';
|
|
24
|
+
import { abortLocalProbe, accountPin, preparePool, fetchRuntimePool, validateAccount, subscriptionUsageStore, poolDeferredError, PoolDeferredError } from './subscription-runtime.js';
|
|
24
25
|
import { selectPoolForResolvedModel } from './subscription-pool.js';
|
|
25
26
|
import { isQuotaPaused, QuotaPausedError } from './quota-pause.js';
|
|
26
27
|
import { redactStartupText } from './startup-auth.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) {
|
|
@@ -911,6 +915,14 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
|
|
|
911
915
|
});
|
|
912
916
|
if (runtime?.account)
|
|
913
917
|
await validateAccount(runtime.account, runtime.resolved);
|
|
918
|
+
if (runtime?.account && !runtime.shouldStop()) {
|
|
919
|
+
const store = subscriptionUsageStore();
|
|
920
|
+
const attemptAt = Date.now();
|
|
921
|
+
const windows = await store.beginVerification(runtime.account, runtime.resolved.model, attemptAt);
|
|
922
|
+
if (windows === undefined)
|
|
923
|
+
throw await poolDeferredError('usage', { kind: 'ready', accounts: [runtime.account] }, runtime.resolved, store);
|
|
924
|
+
runtime.verificationAttemptAt = windows.length ? attemptAt : undefined;
|
|
925
|
+
}
|
|
914
926
|
return new Promise((resolve) => {
|
|
915
927
|
// Preparation above awaits remote config/context. Another thread may
|
|
916
928
|
// have exhausted the shared account meanwhile. No awaits before spawn.
|
|
@@ -1083,6 +1095,16 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
|
|
|
1083
1095
|
// Retain CLI error/result evidence, not the full parsed assistant/tool stream.
|
|
1084
1096
|
const failureEvents = [];
|
|
1085
1097
|
let rateLimitInfo;
|
|
1098
|
+
const rateLimitRejections = new Map();
|
|
1099
|
+
const captureRateLimit = (value) => {
|
|
1100
|
+
rateLimitInfo = parseClaudeRateLimitInfo(value);
|
|
1101
|
+
if (!runtime?.account || rateLimitInfo?.status !== 'rejected')
|
|
1102
|
+
return;
|
|
1103
|
+
for (const w of normalizePassiveUsage(rateLimitInfo, Date.now()).windows) {
|
|
1104
|
+
if (w.status === 'rejected')
|
|
1105
|
+
rateLimitRejections.set(`${w.window}:${w.scope}`, rateLimitInfo);
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1086
1108
|
let metrics;
|
|
1087
1109
|
let lastAssistant;
|
|
1088
1110
|
// teamvibe.ai#284 (poller-brain#235): end-of-turn guard state — tracked
|
|
@@ -1143,7 +1165,7 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
|
|
|
1143
1165
|
// (including metadata keys) to logs, telemetry, typing, or Slack.
|
|
1144
1166
|
const event = safeEvent(rawEvent);
|
|
1145
1167
|
if (rawEvent?.type === 'rate_limit_event') {
|
|
1146
|
-
|
|
1168
|
+
captureRateLimit(event.rate_limit_info);
|
|
1147
1169
|
}
|
|
1148
1170
|
if (rawEvent?.type === 'result' || rawEvent?.type === 'error' || (rawEvent?.type === 'assistant' && rawEvent.error)) {
|
|
1149
1171
|
failureEvents.push(rawEvent);
|
|
@@ -1367,7 +1389,7 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
|
|
|
1367
1389
|
const rawEvent = JSON.parse(stdoutBuffer);
|
|
1368
1390
|
const event = safeEvent(rawEvent);
|
|
1369
1391
|
if (event?.type === 'rate_limit_event') {
|
|
1370
|
-
|
|
1392
|
+
captureRateLimit(event.rate_limit_info);
|
|
1371
1393
|
}
|
|
1372
1394
|
failureEvents.push(rawEvent);
|
|
1373
1395
|
if (event?.type === 'assistant') {
|
|
@@ -1386,6 +1408,7 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
|
|
|
1386
1408
|
attemptedSessionId: sessionId,
|
|
1387
1409
|
...(quota ? { quota } : {}),
|
|
1388
1410
|
...(rateLimitInfo ? { rateLimitInfo } : {}),
|
|
1411
|
+
...(rateLimitRejections.size ? { rateLimitRejections: [...rateLimitRejections.values()] } : {}),
|
|
1389
1412
|
output: redactOutput(stdout),
|
|
1390
1413
|
exitCode: code,
|
|
1391
1414
|
metrics,
|
|
@@ -1610,7 +1633,7 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
|
|
|
1610
1633
|
const modelNormalizedFrom = scoped?.model === resolved.model && scoped?.modelSource === resolved.modelSource
|
|
1611
1634
|
? scoped?.modelNormalizedFrom : undefined;
|
|
1612
1635
|
const runtime = poolRuntime ? {
|
|
1613
|
-
resolved, modelNormalizedFrom, claudeConfigDir, account: prepared?.account,
|
|
1636
|
+
resolved, modelNormalizedFrom, claudeConfigDir, account: prepared?.account, verificationAttemptAt: undefined,
|
|
1614
1637
|
shouldStop: poolRuntime.shouldStop,
|
|
1615
1638
|
resumePending: fetchedPool?.kind !== 'no_accounts_configured' && poolRuntime.pin?.resumePending === true,
|
|
1616
1639
|
onSpawn: () => {
|
|
@@ -1649,10 +1672,12 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
|
|
|
1649
1672
|
if (!runtime?.resumePending)
|
|
1650
1673
|
sawSendMessageCall = false;
|
|
1651
1674
|
const spawned = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, id, runtime?.resumePending ? false : first, lastMessageTs, onMessageSent, typingIndicator, runtime, gitDelivery);
|
|
1675
|
+
const rejected = spawned.rateLimitRejections?.find(rejectedForModel);
|
|
1652
1676
|
const result = prepared?.account
|
|
1653
|
-
? { ...spawned, quota: quotaForModel(spawned.quota), success: rejectedForModel(spawned.rateLimitInfo) ? false : spawned.success } : spawned;
|
|
1677
|
+
? { ...spawned, ...(rejected ? { rateLimitInfo: rejected } : {}), quota: quotaForModel(spawned.quota), success: rejected || rejectedForModel(spawned.rateLimitInfo) ? false : spawned.success } : spawned;
|
|
1654
1678
|
sawSendMessageCall ||= result.sawSendMessageCall === true;
|
|
1655
1679
|
if (prepared?.account) {
|
|
1680
|
+
const observedAt = Date.now();
|
|
1656
1681
|
if (poolAttempts.length >= 1000)
|
|
1657
1682
|
poolAttempts.shift();
|
|
1658
1683
|
const selection = prepared.selection;
|
|
@@ -1664,7 +1689,8 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
|
|
|
1664
1689
|
accountId: prepared.account.accountId,
|
|
1665
1690
|
credentialVersion: prepared.account.credentialVersion,
|
|
1666
1691
|
accountLabel: prepared.account.label ?? prepared.account.accountId,
|
|
1667
|
-
observedAt
|
|
1692
|
+
observedAt,
|
|
1693
|
+
verificationAttemptAt: runtime?.verificationAttemptAt,
|
|
1668
1694
|
rateLimitInfo: result.rateLimitInfo,
|
|
1669
1695
|
sessionId: id,
|
|
1670
1696
|
model: resolved.model ?? result.metrics?.model,
|
|
@@ -1675,13 +1701,19 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
|
|
|
1675
1701
|
metrics: result.metrics,
|
|
1676
1702
|
});
|
|
1677
1703
|
emitEvent('pool.account_attempted', msg.teamvibe.channelId, { workspaceId: msg.teamvibe.workspaceId, threadId: msg.thread_id, ...poolRunTelemetry(poolAttempts, poolRuntime?.quotaDeferEpisodes) });
|
|
1704
|
+
// Retain every rejected scope before completion can clear a verified
|
|
1705
|
+
// hold or return early. A failed write must not skip another scope.
|
|
1706
|
+
const rejectionOutcomes = await Promise.allSettled((result.rateLimitRejections ?? []).map(info => logPoolUsageWrite(sessionLog, prepared.account, info, () => prepared.store.recordPassive(prepared.account, info, observedAt, { expireFutureObservations: true }))));
|
|
1678
1707
|
if (result.success && id && poolRuntime) {
|
|
1679
|
-
// Complete the inbox
|
|
1680
|
-
//
|
|
1708
|
+
// Complete the inbox even if rejection bookkeeping failed. The loop
|
|
1709
|
+
// retains completion independently of the original prompt.
|
|
1681
1710
|
const bookkeepingComplete = await poolRuntime.complete(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: false }, poolAttempts, result.rateLimitInfo);
|
|
1682
1711
|
if (bookkeepingComplete !== false)
|
|
1683
1712
|
await emitCandidateUsage(msg, [prepared.account], prepared.store);
|
|
1684
1713
|
retainWorktree = false;
|
|
1714
|
+
const failed = rejectionOutcomes.find((outcome) => outcome.status === 'rejected');
|
|
1715
|
+
if (failed)
|
|
1716
|
+
throw new PoolDeferredError('infrastructure', { error: failed.reason });
|
|
1685
1717
|
return result;
|
|
1686
1718
|
}
|
|
1687
1719
|
if ((result.quota || rejectedForModel(result.rateLimitInfo)) && !result.killedByPoller) {
|
|
@@ -1692,15 +1724,17 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
|
|
|
1692
1724
|
// retaining the attempt. Selection waits for all writes before switching.
|
|
1693
1725
|
const writes = [];
|
|
1694
1726
|
if (result.quota && !result.killedByPoller)
|
|
1695
|
-
writes.push(logPoolUsageWrite(sessionLog, prepared.account, result.quota.window ? { rateLimitType: result.quota.window } : undefined, () => prepared.store.recordQuota(prepared.account, result.quota,
|
|
1696
|
-
if (result.rateLimitInfo)
|
|
1697
|
-
writes.push(logPoolUsageWrite(sessionLog, prepared.account, result.rateLimitInfo, () => prepared.store.recordPassive(prepared.account, result.rateLimitInfo,
|
|
1727
|
+
writes.push(logPoolUsageWrite(sessionLog, prepared.account, result.quota.window ? { rateLimitType: result.quota.window } : undefined, () => prepared.store.recordQuota(prepared.account, result.quota, observedAt, { expireFutureObservations: true }), 'quota'));
|
|
1728
|
+
if (!result.rateLimitRejections?.length && result.rateLimitInfo)
|
|
1729
|
+
writes.push(logPoolUsageWrite(sessionLog, prepared.account, result.rateLimitInfo, () => prepared.store.recordPassive(prepared.account, result.rateLimitInfo, observedAt, { expireFutureObservations: true })));
|
|
1698
1730
|
if (id && poolRuntime)
|
|
1699
1731
|
writes.push(poolRuntime.persist(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: runtime?.resumePending, sawSendMessageCall }, poolAttempts));
|
|
1700
|
-
const outcomes = await Promise.allSettled(writes);
|
|
1732
|
+
const outcomes = [...rejectionOutcomes, ...await Promise.allSettled(writes)];
|
|
1701
1733
|
const failed = outcomes.find((outcome) => outcome.status === 'rejected');
|
|
1702
1734
|
if (failed)
|
|
1703
1735
|
throw new PoolDeferredError('infrastructure', { error: failed.reason });
|
|
1736
|
+
if (runtime?.verificationAttemptAt !== undefined && !result.killedByPoller && result.exitCode !== null)
|
|
1737
|
+
await prepared.store.completeVerification(prepared.account, runtime.verificationAttemptAt, observedAt);
|
|
1704
1738
|
}
|
|
1705
1739
|
return result;
|
|
1706
1740
|
};
|
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
|