typeclaw 0.28.0 → 0.28.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/package.json +1 -1
- package/src/agent/provider-error.ts +33 -1
- package/src/agent/tools/channel-reply.ts +22 -0
- package/src/agent/tools/channel-send.ts +21 -0
- package/src/channels/adapters/github/index.ts +10 -0
- package/src/channels/adapters/github/review-state.ts +137 -0
- package/src/channels/github-rereview-guard.ts +76 -0
- package/src/channels/github-review-claim.ts +5 -4
- package/src/channels/router.ts +179 -7
- package/src/channels/types.ts +31 -0
- package/src/inspect/transcript-view.ts +10 -0
- package/src/server/index.ts +11 -1
- package/src/shared/protocol.ts +18 -6
- package/src/tui/format.ts +13 -0
- package/src/tui/index.ts +21 -7
package/package.json
CHANGED
|
@@ -13,7 +13,39 @@ import type { AgentSession } from './index'
|
|
|
13
13
|
// not by this helper.
|
|
14
14
|
|
|
15
15
|
export type DetectedProviderError = {
|
|
16
|
+
// Raw provider text. Safe for logs and operator-only surfaces (TUI,
|
|
17
|
+
// `typeclaw logs`), but NOT for channels — see `safeMessage`.
|
|
16
18
|
message: string
|
|
19
|
+
// Redacted, user-facing variant for public/multi-user channels. Known-safe
|
|
20
|
+
// operational classes (rate/usage limit, billing/quota) map to a canonical
|
|
21
|
+
// sentence; everything else (malformed-response SDK dumps, unknown failures)
|
|
22
|
+
// collapses to a generic notice so provider response bodies, URLs, or tokens
|
|
23
|
+
// can never leak to a channel.
|
|
24
|
+
safeMessage: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const GENERIC_SAFE_NOTICE = 'The upstream LLM provider failed. Operators can check `typeclaw logs` for details.'
|
|
28
|
+
|
|
29
|
+
// Each entry pairs a narrow matcher against the raw provider text with the
|
|
30
|
+
// canonical, leak-free sentence shown in channels. Matchers are intentionally
|
|
31
|
+
// specific: a miss falls through to GENERIC_SAFE_NOTICE rather than echoing raw
|
|
32
|
+
// text, so adding a new class is opt-in and never widens what we expose.
|
|
33
|
+
const SAFE_CLASSES: ReadonlyArray<{ match: RegExp; safe: string }> = [
|
|
34
|
+
{
|
|
35
|
+
match: /\b(usage limit|rate limit|rate.?limited|too many requests|429)\b/i,
|
|
36
|
+
safe: 'The upstream LLM provider is rate-limited (usage limit reached). Try again shortly.',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
match: /\b(billing|quota|insufficient.*(credit|fund|balance)|payment|account is not active)\b/i,
|
|
40
|
+
safe: 'The upstream LLM provider rejected the request for a billing/quota reason. Operators can check `typeclaw logs` for details.',
|
|
41
|
+
},
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
function toSafeMessage(raw: string): string {
|
|
45
|
+
for (const { match, safe } of SAFE_CLASSES) {
|
|
46
|
+
if (match.test(raw)) return safe
|
|
47
|
+
}
|
|
48
|
+
return GENERIC_SAFE_NOTICE
|
|
17
49
|
}
|
|
18
50
|
|
|
19
51
|
export function detectProviderError(message: unknown): DetectedProviderError | null {
|
|
@@ -25,7 +57,7 @@ export function detectProviderError(message: unknown): DetectedProviderError | n
|
|
|
25
57
|
// ignore aborts (no surface to render them on).
|
|
26
58
|
if (m.stopReason !== 'error') return null
|
|
27
59
|
const text = typeof m.errorMessage === 'string' && m.errorMessage.length > 0 ? m.errorMessage : 'LLM call failed'
|
|
28
|
-
return { message: text }
|
|
60
|
+
return { message: text, safeMessage: toSafeMessage(text) }
|
|
29
61
|
}
|
|
30
62
|
|
|
31
63
|
export type ProviderErrorListener = (error: DetectedProviderError) => void
|
|
@@ -2,6 +2,7 @@ import { Type } from '@mariozechner/pi-ai'
|
|
|
2
2
|
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
3
|
|
|
4
4
|
import { checkFalseReceipt } from '@/channels/github-false-receipt'
|
|
5
|
+
import { evaluateRereviewGuard } from '@/channels/github-rereview-guard'
|
|
5
6
|
import {
|
|
6
7
|
containsKimiToolDelimiter,
|
|
7
8
|
isNoReplySignal,
|
|
@@ -159,6 +160,27 @@ export function createChannelReplyTool({
|
|
|
159
160
|
}
|
|
160
161
|
const falseReceiptNotice = falseReceipt.kind === 'warn' ? falseReceipt.notice : null
|
|
161
162
|
|
|
163
|
+
// Re-review stranding guard: block a thread close-out / verdict ack while
|
|
164
|
+
// the bot still holds its own CHANGES_REQUESTED on this PR, so it can't
|
|
165
|
+
// silently leave the PR blocked (PR #644). Runs before the resolve so a
|
|
166
|
+
// blocked close-out never mutates the thread.
|
|
167
|
+
const rereview = await evaluateRereviewGuard({
|
|
168
|
+
adapter: origin.adapter,
|
|
169
|
+
workspace: origin.workspace,
|
|
170
|
+
chat: origin.chat,
|
|
171
|
+
thread: origin.thread,
|
|
172
|
+
text,
|
|
173
|
+
wantsResolve: params.resolve_review_thread === true,
|
|
174
|
+
getReviewState: (req) => router.getReviewState(req),
|
|
175
|
+
})
|
|
176
|
+
if (rereview.block) {
|
|
177
|
+
logger.warn(formatChannelToolFailure('channel_reply', rereview.reason))
|
|
178
|
+
return {
|
|
179
|
+
content: [{ type: 'text' as const, text: `channel_reply denied: ${rereview.reason}` }],
|
|
180
|
+
details: { ok: false, error: rereview.reason },
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
162
184
|
// Resolve BEFORE posting: a successful channel_reply ends the turn, so a
|
|
163
185
|
// resolve attempted "after" the ack would never run (the exact bug this
|
|
164
186
|
// flag fixes). Resolve-failure blocks the reply so the agent never posts
|
|
@@ -2,6 +2,7 @@ import { Type } from '@mariozechner/pi-ai'
|
|
|
2
2
|
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
3
|
|
|
4
4
|
import { checkFalseReceipt } from '@/channels/github-false-receipt'
|
|
5
|
+
import { evaluateRereviewGuard } from '@/channels/github-rereview-guard'
|
|
5
6
|
import { recordResolvedThread } from '@/channels/github-review-turn-ledger'
|
|
6
7
|
import {
|
|
7
8
|
containsKimiToolDelimiter,
|
|
@@ -176,6 +177,26 @@ export function createChannelSendTool({
|
|
|
176
177
|
}
|
|
177
178
|
const falseReceiptNotice = falseReceipt.kind === 'warn' ? falseReceipt.notice : null
|
|
178
179
|
|
|
180
|
+
// Re-review stranding guard (mirrors channel_reply): block a thread
|
|
181
|
+
// close-out / verdict ack while the bot still holds its own
|
|
182
|
+
// CHANGES_REQUESTED on this PR, before the resolve mutates the thread.
|
|
183
|
+
const rereview = await evaluateRereviewGuard({
|
|
184
|
+
adapter,
|
|
185
|
+
workspace: params.workspace,
|
|
186
|
+
chat: params.chat,
|
|
187
|
+
thread: params.thread ?? null,
|
|
188
|
+
text: bodyText,
|
|
189
|
+
wantsResolve,
|
|
190
|
+
getReviewState: (req) => router.getReviewState(req),
|
|
191
|
+
})
|
|
192
|
+
if (rereview.block) {
|
|
193
|
+
logger.warn(formatChannelToolFailure('channel_send', rereview.reason))
|
|
194
|
+
return {
|
|
195
|
+
content: [{ type: 'text' as const, text: `channel_send denied: ${rereview.reason}` }],
|
|
196
|
+
details: { ok: false, error: rereview.reason },
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
179
200
|
// Resolve BEFORE posting (mirrors channel_reply): a failed resolve must
|
|
180
201
|
// block the acknowledgement so the bot never posts "addressed — resolving"
|
|
181
202
|
// next to a still-open thread. The router enforces that only the bot's own
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
parseListHooksPermissionStatus,
|
|
22
22
|
} from './permission-guidance'
|
|
23
23
|
import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
|
|
24
|
+
import { createGithubReviewStateResolver } from './review-state'
|
|
24
25
|
import { createGithubReviewThreadResolver } from './review-thread-resolver'
|
|
25
26
|
import { createTeamMembershipChecker } from './team-membership'
|
|
26
27
|
import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
|
|
@@ -143,6 +144,12 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
143
144
|
selfLogin: () => selfLogin,
|
|
144
145
|
fetchImpl,
|
|
145
146
|
})
|
|
147
|
+
const reviewStateResolver = createGithubReviewStateResolver({
|
|
148
|
+
token: authToken,
|
|
149
|
+
selfLogin: () => selfLogin,
|
|
150
|
+
approve: () => options.configRef().review.approve,
|
|
151
|
+
fetchImpl,
|
|
152
|
+
})
|
|
146
153
|
const channelNameResolver = createGithubChannelNameResolver({ token: authToken, fetchImpl })
|
|
147
154
|
// GitHub addresses by `@login`, not the numeric id, so `username` carries
|
|
148
155
|
// the login the model should type; the id is kept for completeness.
|
|
@@ -195,6 +202,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
195
202
|
options.router.registerChannelNameResolver('github', channelNameResolver)
|
|
196
203
|
options.router.registerSelfIdentity('github', selfIdentityResolver)
|
|
197
204
|
options.router.registerReviewThreadResolver('github', reviewThreadResolver)
|
|
205
|
+
options.router.registerReviewStateResolver('github', reviewStateResolver)
|
|
198
206
|
options.router.registerFetchAttachment('github', fetchAttachment)
|
|
199
207
|
try {
|
|
200
208
|
server = (options.httpListenImpl ?? listenWithBun)(options.configRef().webhookPort, handler)
|
|
@@ -210,6 +218,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
210
218
|
options.router.unregisterChannelNameResolver('github', channelNameResolver)
|
|
211
219
|
options.router.unregisterSelfIdentity('github', selfIdentityResolver)
|
|
212
220
|
options.router.unregisterReviewThreadResolver('github', reviewThreadResolver)
|
|
221
|
+
options.router.unregisterReviewStateResolver('github', reviewStateResolver)
|
|
213
222
|
options.router.unregisterFetchAttachment('github', fetchAttachment)
|
|
214
223
|
await auth.dispose()
|
|
215
224
|
delete process.env.GH_TOKEN
|
|
@@ -334,6 +343,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
334
343
|
options.router.unregisterChannelNameResolver('github', channelNameResolver)
|
|
335
344
|
options.router.unregisterSelfIdentity('github', selfIdentityResolver)
|
|
336
345
|
options.router.unregisterReviewThreadResolver('github', reviewThreadResolver)
|
|
346
|
+
options.router.unregisterReviewStateResolver('github', reviewStateResolver)
|
|
337
347
|
options.router.unregisterFetchAttachment('github', fetchAttachment)
|
|
338
348
|
await server?.stop()
|
|
339
349
|
// Detach hooks AFTER closing the listener so any in-flight deliveries
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { ReviewStateResolver, ReviewStateResult } from '@/channels/types'
|
|
2
|
+
|
|
3
|
+
import type { GithubAuthContext } from './auth'
|
|
4
|
+
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
5
|
+
|
|
6
|
+
// Answers the re-review stranding guard's question: is the bot's latest
|
|
7
|
+
// EFFECTIVE formal review on this PR a sticky CHANGES_REQUESTED? GitHub clears a
|
|
8
|
+
// same-reviewer CHANGES_REQUESTED only with a later APPROVED or DISMISSED from
|
|
9
|
+
// the same reviewer — a later COMMENTED review does NOT clear it (the PR #644
|
|
10
|
+
// trap). So we walk the bot's own reviews in chronological order, ignore
|
|
11
|
+
// COMMENTED/PENDING, and read the last decisive one.
|
|
12
|
+
export function createGithubReviewStateResolver(deps: {
|
|
13
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
14
|
+
selfLogin: () => string | null
|
|
15
|
+
approve: () => boolean
|
|
16
|
+
fetchImpl?: typeof fetch
|
|
17
|
+
}): ReviewStateResolver {
|
|
18
|
+
const fetchImpl = deps.fetchImpl ?? fetch
|
|
19
|
+
return async (req): Promise<ReviewStateResult> => {
|
|
20
|
+
const approve = deps.approve()
|
|
21
|
+
if (req.adapter !== 'github') {
|
|
22
|
+
return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
|
|
23
|
+
}
|
|
24
|
+
const target = parseTarget(req.workspace, req.chat)
|
|
25
|
+
if (target === null) {
|
|
26
|
+
return { ok: false, error: `unparseable github PR target (chat=${req.chat})`, code: 'transient' }
|
|
27
|
+
}
|
|
28
|
+
const selfLogin = deps.selfLogin()
|
|
29
|
+
if (selfLogin === null) {
|
|
30
|
+
return { ok: false, error: 'github self-identity not resolved; cannot read review state', code: 'transient' }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const token = await deps.token({ repoSlug: `${target.owner}/${target.repo}` })
|
|
34
|
+
const reviews = await fetchSelfReviews(fetchImpl, token, target, selfLogin)
|
|
35
|
+
if (!reviews.ok) return { ok: false, error: reviews.error, code: reviews.code }
|
|
36
|
+
|
|
37
|
+
const lastDecisive = reviews.states.filter(isDecisive).at(-1) ?? null
|
|
38
|
+
return { ok: true, selfBlocking: lastDecisive === 'CHANGES_REQUESTED', approve }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Target = { owner: string; repo: string; prNumber: number }
|
|
43
|
+
|
|
44
|
+
function parseTarget(workspace: string, chat: string): Target | null {
|
|
45
|
+
const [owner, repo, ...rest] = workspace.split('/')
|
|
46
|
+
if (owner === undefined || owner === '' || repo === undefined || repo === '' || rest.length > 0) return null
|
|
47
|
+
const m = /^pr:(\d+)$/.exec(chat)
|
|
48
|
+
if (m === null) return null
|
|
49
|
+
const prNumber = Number(m[1])
|
|
50
|
+
if (!Number.isSafeInteger(prNumber) || prNumber <= 0) return null
|
|
51
|
+
return { owner, repo, prNumber }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type SelfReviewsResult =
|
|
55
|
+
| { ok: true; states: string[] }
|
|
56
|
+
| { ok: false; error: string; code: 'not-found' | 'permission-denied' | 'transient' }
|
|
57
|
+
|
|
58
|
+
async function fetchSelfReviews(
|
|
59
|
+
fetchImpl: typeof fetch,
|
|
60
|
+
token: string,
|
|
61
|
+
target: Target,
|
|
62
|
+
selfLogin: string,
|
|
63
|
+
): Promise<SelfReviewsResult> {
|
|
64
|
+
const states: string[] = []
|
|
65
|
+
let url: string | null =
|
|
66
|
+
`${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/pulls/${target.prNumber}/reviews?per_page=100`
|
|
67
|
+
while (url !== null) {
|
|
68
|
+
let response: Response
|
|
69
|
+
try {
|
|
70
|
+
response = await fetchImpl(url, { headers: githubJsonHeaders(token) })
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
|
|
73
|
+
}
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const text = await response.text().catch(() => '')
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: `GitHub reviews ${response.status}${text !== '' ? `: ${text}` : ''}`,
|
|
79
|
+
code: classifyStatus(response.status),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const page = (await response.json().catch(() => null)) as ReviewRow[] | null
|
|
83
|
+
if (page === null) return { ok: false, error: 'GitHub reviews returned non-JSON', code: 'transient' }
|
|
84
|
+
for (const row of page) {
|
|
85
|
+
if (typeof row.state !== 'string') continue
|
|
86
|
+
const login = row.user?.login ?? null
|
|
87
|
+
if (login === null) continue
|
|
88
|
+
const isBot = row.user?.type === 'Bot'
|
|
89
|
+
if (!isSelfReviewer(login, isBot, selfLogin)) continue
|
|
90
|
+
states.push(row.state)
|
|
91
|
+
}
|
|
92
|
+
url = nextLink(response.headers.get('link'))
|
|
93
|
+
}
|
|
94
|
+
return { ok: true, states }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A formal CHANGES_REQUESTED is sticky until a later APPROVED/DISMISSED; only
|
|
98
|
+
// these three states decide the block. COMMENTED and PENDING are non-deciding
|
|
99
|
+
// noise that must NOT shadow an earlier CHANGES_REQUESTED.
|
|
100
|
+
const DECISIVE = new Set(['CHANGES_REQUESTED', 'APPROVED', 'DISMISSED'])
|
|
101
|
+
|
|
102
|
+
function isDecisive(state: string): boolean {
|
|
103
|
+
return DECISIVE.has(state)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A GitHub App's own login differs across REST (`slug[bot]`) and GraphQL (bare
|
|
107
|
+
// `slug`). The REST reviews endpoint returns `slug[bot]` for the App, but the
|
|
108
|
+
// suffix-strip must be gated on the reviewer actually being a Bot: a human User
|
|
109
|
+
// can own the bare slug as a login, and stripping `[bot]` off the App's
|
|
110
|
+
// selfLogin to match a human would wrongly attribute their review to the bot.
|
|
111
|
+
const BOT_LOGIN_SUFFIX = '[bot]'
|
|
112
|
+
|
|
113
|
+
function isSelfReviewer(login: string, isBot: boolean, selfLogin: string): boolean {
|
|
114
|
+
if (isBot) return normalizeBotLogin(login) === normalizeBotLogin(selfLogin)
|
|
115
|
+
return login === selfLogin
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeBotLogin(login: string): string {
|
|
119
|
+
return login.endsWith(BOT_LOGIN_SUFFIX) ? login.slice(0, -BOT_LOGIN_SUFFIX.length) : login
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function nextLink(linkHeader: string | null): string | null {
|
|
123
|
+
if (linkHeader === null) return null
|
|
124
|
+
for (const part of linkHeader.split(',')) {
|
|
125
|
+
const m = /<([^>]+)>;\s*rel="next"/.exec(part)
|
|
126
|
+
if (m !== null) return m[1] ?? null
|
|
127
|
+
}
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function classifyStatus(status: number): 'not-found' | 'permission-denied' | 'transient' {
|
|
132
|
+
if (status === 401 || status === 403) return 'permission-denied'
|
|
133
|
+
if (status === 404) return 'not-found'
|
|
134
|
+
return 'transient'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type ReviewRow = { id?: number; state?: unknown; user?: { login?: string; type?: string } }
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { classifyReviewClaim } from './github-review-claim'
|
|
2
|
+
import type { ReviewStateResult } from './types'
|
|
3
|
+
|
|
4
|
+
// The re-review stranding guard. A bot that resolves a review thread (or posts a
|
|
5
|
+
// close-out ack) while it still holds its own sticky CHANGES_REQUESTED leaves the
|
|
6
|
+
// PR blocked forever — the resolve/ack carries no review state, so GitHub's
|
|
7
|
+
// reviewDecision never clears (PR #644). This guard blocks that close-out and
|
|
8
|
+
// tells the model to land a formal APPROVE / dismissal first.
|
|
9
|
+
//
|
|
10
|
+
// It is the same enforcement seam as the false-receipt guard and the
|
|
11
|
+
// resolve-thread author check: BLOCK and instruct, never act on the model's
|
|
12
|
+
// behalf — the runtime cannot prove a semantic approval from "one thread closed".
|
|
13
|
+
|
|
14
|
+
export type RereviewGuardInput = {
|
|
15
|
+
adapter: string
|
|
16
|
+
chat: string
|
|
17
|
+
thread: string | null
|
|
18
|
+
text: string | undefined
|
|
19
|
+
wantsResolve: boolean
|
|
20
|
+
getReviewState: (req: { adapter: 'github'; workspace: string; chat: string }) => Promise<ReviewStateResult>
|
|
21
|
+
workspace: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type RereviewGuardDecision = { block: false } | { block: true; reason: string }
|
|
25
|
+
|
|
26
|
+
const ALLOW: RereviewGuardDecision = { block: false }
|
|
27
|
+
|
|
28
|
+
export async function evaluateRereviewGuard(input: RereviewGuardInput): Promise<RereviewGuardDecision> {
|
|
29
|
+
if (input.adapter !== 'github') return ALLOW
|
|
30
|
+
if (!/^pr:\d+$/.test(input.chat)) return ALLOW
|
|
31
|
+
// No `thread === null` exemption: a top-level PR comment carries no thread but
|
|
32
|
+
// a close-out ack in it ("Verified — that closes it") strands the block just
|
|
33
|
+
// as a thread reply would. Only the resolve ACTION needs a thread; the
|
|
34
|
+
// text-claim path fires regardless (caught by isCloseoutAttempt below).
|
|
35
|
+
if (!isCloseoutAttempt(input.wantsResolve, input.thread, input.text)) return ALLOW
|
|
36
|
+
|
|
37
|
+
const state = await input.getReviewState({ adapter: 'github', workspace: input.workspace, chat: input.chat })
|
|
38
|
+
|
|
39
|
+
// Fail closed: an unverifiable review state is treated as a live block, so the
|
|
40
|
+
// bot never strands a re-review on a transient API failure.
|
|
41
|
+
if (!state.ok) return { block: true, reason: unverifiableReason(state.error) }
|
|
42
|
+
if (!state.selfBlocking) return ALLOW
|
|
43
|
+
|
|
44
|
+
return { block: true, reason: state.approve ? STICKY_BLOCK_APPROVE_ENABLED : STICKY_BLOCK_APPROVE_DISABLED }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Trigger when the model asks to resolve a thread (only meaningful with a
|
|
48
|
+
// thread), OR when its reply reads as a close-out/verdict claim — the latter
|
|
49
|
+
// strands the block whether or not it sits in a thread, so it fires for any PR
|
|
50
|
+
// chat. Plain discussion replies (ignore/warn) do not fire.
|
|
51
|
+
function isCloseoutAttempt(wantsResolve: boolean, thread: string | null, text: string | undefined): boolean {
|
|
52
|
+
if (wantsResolve && thread !== null) return true
|
|
53
|
+
const claim = classifyReviewClaim(text ?? '')
|
|
54
|
+
return claim === 'block-resolve' || claim === 'block-approve'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function unverifiableReason(error: string): string {
|
|
58
|
+
return (
|
|
59
|
+
'Could not verify whether your prior CHANGES_REQUESTED on this PR is still live ' +
|
|
60
|
+
`(${error}). Refusing to close out the thread while the block state is unknown — ` +
|
|
61
|
+
'retry once the GitHub API is reachable, or land a formal review verdict first.'
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const STICKY_BLOCK_APPROVE_ENABLED =
|
|
66
|
+
'You still hold a CHANGES_REQUESTED on this PR. Resolving the thread (or posting a close-out ack) ' +
|
|
67
|
+
'does NOT clear it — only a fresh formal review does. Submit `APPROVE` via ' +
|
|
68
|
+
'`gh api -X POST /repos/<owner>/<repo>/pulls/<N>/reviews` (event: APPROVE) if the blockers are fixed, ' +
|
|
69
|
+
'or `REQUEST_CHANGES` if not, THEN resolve the thread / reply.'
|
|
70
|
+
|
|
71
|
+
const STICKY_BLOCK_APPROVE_DISABLED =
|
|
72
|
+
'You still hold a CHANGES_REQUESTED on this PR and resolving the thread does NOT clear it. ' +
|
|
73
|
+
'Approval is disabled for this agent (channels.github.review.approve: false), so you cannot APPROVE — ' +
|
|
74
|
+
'dismiss your prior review via ' +
|
|
75
|
+
'`gh api -X PUT /repos/<owner>/<repo>/pulls/<N>/reviews/<review_id>/dismissals -f message="..." -f event=DISMISS` ' +
|
|
76
|
+
'if the blockers are fixed (or submit REQUEST_CHANGES if not), THEN resolve the thread / reply.'
|
|
@@ -24,15 +24,16 @@ const BLOCK_REQUEST_CHANGES: readonly RegExp[] = [
|
|
|
24
24
|
/\bthis is blocked\b/,
|
|
25
25
|
]
|
|
26
26
|
|
|
27
|
-
//
|
|
28
|
-
// "resolved"
|
|
29
|
-
//
|
|
30
|
-
//
|
|
27
|
+
// Bare "resolved" is intentionally NOT here — it collides with the warn-tier
|
|
28
|
+
// "looks resolved?"; resolve claims must carry a definite marker (marked/that/
|
|
29
|
+
// this/thanks) or a verify clause. "that/this closes it" is the canonical PR
|
|
30
|
+
// #644 incident phrasing and must classify as a close-out claim.
|
|
31
31
|
const BLOCK_RESOLVE: readonly RegExp[] = [
|
|
32
32
|
/\bmarked resolved\b/,
|
|
33
33
|
/\bthread resolved\b/,
|
|
34
34
|
/\bthat resolves it\b/,
|
|
35
35
|
/\bthis resolves it\b/,
|
|
36
|
+
/\b(that|this) closes it\b/,
|
|
36
37
|
/\bclosing this out\b/,
|
|
37
38
|
/\bconfirmed fixed\b/,
|
|
38
39
|
// verify clause + a fix/resolve verb, allowing a short gap ("verified at <sha>, that fixes it").
|
package/src/channels/router.ts
CHANGED
|
@@ -75,6 +75,9 @@ import type {
|
|
|
75
75
|
ReactionRequest,
|
|
76
76
|
ReactionResult,
|
|
77
77
|
ResolvedChannelNames,
|
|
78
|
+
ReviewStateRequest,
|
|
79
|
+
ReviewStateResolver,
|
|
80
|
+
ReviewStateResult,
|
|
78
81
|
ReviewThreadResolveRequest,
|
|
79
82
|
ReviewThreadResolveResult,
|
|
80
83
|
ReviewThreadResolver,
|
|
@@ -178,6 +181,44 @@ export const MAX_POLICY_DENIED_CHANNEL_SENDS_PER_TURN = 3
|
|
|
178
181
|
// including reasoning). Deliberately NOT lowered in `providers.ts`, where
|
|
179
182
|
// `maxTokens` is the model's true capability that compaction math reads.
|
|
180
183
|
export const CHANNEL_MAX_OUTPUT_TOKENS = 4096
|
|
184
|
+
// Ceiling on automatic re-prompts for a turn that ended with NO user-facing
|
|
185
|
+
// reply AND no attempted send — the pure "the model burned its budget thinking
|
|
186
|
+
// and produced nothing" failure. The canonical trigger is Fireworks'
|
|
187
|
+
// kimi-k2p6-turbo spiraling into a long reasoning loop on an ambiguous request
|
|
188
|
+
// until it hits CHANNEL_MAX_OUTPUT_TOKENS (`stopReason: 'length'`); the same
|
|
189
|
+
// path also catches a provider/router `aborted` leaf that left no recoverable
|
|
190
|
+
// prose. Each retry injects EMPTY_TURN_RETRY_NUDGE as a reminder-only turn (no
|
|
191
|
+
// new inbound) so `drain()` re-runs `session.prompt()` against the same branch.
|
|
192
|
+
// Bounded because a genuinely stuck model would otherwise re-loop forever; on
|
|
193
|
+
// exhaustion the user gets EMPTY_TURN_FALLBACK_TEXT instead of dead air. Reset
|
|
194
|
+
// at turn start alongside `turnSeq`. Deliberately NOT applied to turns that
|
|
195
|
+
// ATTEMPTED a send this turn (skip-locked or policy-denied) — those already
|
|
196
|
+
// thrashed the send path, so a re-prompt would just re-thrash; they skip
|
|
197
|
+
// straight to the fallback. See validateChannelTurn's candidate===null branch.
|
|
198
|
+
export const MAX_EMPTY_TURN_RETRIES = 2
|
|
199
|
+
// Reminder-only nudge injected before an empty-turn retry. Uses the repo's
|
|
200
|
+
// SYSTEM MESSAGE framing (see composeTurnPrompt) so persona-rich models do not
|
|
201
|
+
// reply to the notice itself. Neutral by design: it asks for a direct reply
|
|
202
|
+
// without prescribing length or tone, matching the chosen "just retry" posture.
|
|
203
|
+
export const EMPTY_TURN_RETRY_NUDGE = [
|
|
204
|
+
'---',
|
|
205
|
+
'**[SYSTEM MESSAGE — not from a human]**',
|
|
206
|
+
'',
|
|
207
|
+
'Your previous turn ended without sending any reply to the channel. This is',
|
|
208
|
+
'an automated signal from the channel router, not a message from anyone in',
|
|
209
|
+
'the chat. **Do not acknowledge or reply to this notice itself.**',
|
|
210
|
+
'',
|
|
211
|
+
'Respond to the last user message now with a direct answer via your channel',
|
|
212
|
+
'reply tool. If you genuinely have nothing to say, reply with `NO_REPLY`.',
|
|
213
|
+
'',
|
|
214
|
+
'---',
|
|
215
|
+
].join('\n')
|
|
216
|
+
// Posted to the channel (via the `source:'system'` one-shot bypass) when an
|
|
217
|
+
// empty turn cannot be recovered AND retries are exhausted (or are skipped
|
|
218
|
+
// because the turn thrashed the send path). Replaces the historical silent
|
|
219
|
+
// drop so the human is never left staring at dead air after a degenerate turn.
|
|
220
|
+
export const EMPTY_TURN_FALLBACK_TEXT =
|
|
221
|
+
"⚠️ I got stuck putting together a reply and couldn't finish. Could you rephrase or try again?"
|
|
181
222
|
// Rolling window for outbound send-rate telemetry. 5s matches Discord's
|
|
182
223
|
// rate-limit shape (5 msg / 5 s / channel) and comfortably covers Slack's
|
|
183
224
|
// 1 msg/s sustained. The window is observational; exceeding the burst
|
|
@@ -481,6 +522,14 @@ type LiveSession = {
|
|
|
481
522
|
// livelock the byte-identical loop-guard misses. Reset at turn start and
|
|
482
523
|
// cleared per-target on a successful delivery to that target.
|
|
483
524
|
policyDeniedToolSendsThisTurn: Map<string, number>
|
|
525
|
+
// Count of automatic empty-turn re-prompts already spent on the CURRENT
|
|
526
|
+
// logical turn, bounded by `MAX_EMPTY_TURN_RETRIES`. A "logical turn" spans
|
|
527
|
+
// the original user batch plus any router-injected retry nudges, so this is
|
|
528
|
+
// reset only when a real user/reminder batch starts a fresh turn — NOT on the
|
|
529
|
+
// reminder-only iterations the retry itself queues. `validateChannelTurn`
|
|
530
|
+
// increments it before injecting EMPTY_TURN_RETRY_NUDGE and reads it to decide
|
|
531
|
+
// retry-vs-fallback. See the candidate===null branch.
|
|
532
|
+
emptyTurnRetries: number
|
|
484
533
|
// Stamped by `markTurnSkipped` (called from the `skip_response` tool)
|
|
485
534
|
// with the current `turnSeq`. Read at the top of `validateChannelTurn`:
|
|
486
535
|
// if it matches the just-completed turn, recovery is skipped entirely
|
|
@@ -635,6 +684,12 @@ export type ChannelRouter = {
|
|
|
635
684
|
registerReviewThreadResolver: (adapter: ChannelKey['adapter'], resolver: ReviewThreadResolver) => void
|
|
636
685
|
unregisterReviewThreadResolver: (adapter: ChannelKey['adapter'], resolver: ReviewThreadResolver) => void
|
|
637
686
|
resolveReviewThread: (req: ReviewThreadResolveRequest) => Promise<ReviewThreadResolveResult>
|
|
687
|
+
// Re-review stranding guard support: answers whether the bot still holds a
|
|
688
|
+
// blocking CHANGES_REQUESTED on a PR. Opt-in per adapter like the thread
|
|
689
|
+
// resolver; `getReviewState` answers `unsupported` when none is registered.
|
|
690
|
+
registerReviewStateResolver: (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver) => void
|
|
691
|
+
unregisterReviewStateResolver: (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver) => void
|
|
692
|
+
getReviewState: (req: ReviewStateRequest) => Promise<ReviewStateResult>
|
|
638
693
|
lookupInboundAttachment: (args: ChannelKey & { id: number }) => InboundAttachment | null
|
|
639
694
|
listInboundAttachmentIds: (args: ChannelKey) => readonly number[]
|
|
640
695
|
// Execute a command by name against an existing live session, bypassing
|
|
@@ -905,6 +960,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
905
960
|
const historyCallbacks = new Map<ChannelKey['adapter'], Set<HistoryCallback>>()
|
|
906
961
|
const fetchAttachmentCallbacks = new Map<ChannelKey['adapter'], Set<FetchAttachmentCallback>>()
|
|
907
962
|
const reviewThreadResolvers = new Map<ChannelKey['adapter'], ReviewThreadResolver>()
|
|
963
|
+
const reviewStateResolvers = new Map<ChannelKey['adapter'], ReviewStateResolver>()
|
|
908
964
|
const stickyLedger = new StickyLedger()
|
|
909
965
|
// The /help handler reads the live registry to enumerate commands, so it
|
|
910
966
|
// forward-references `commands`. Safe at runtime — the handler only runs on
|
|
@@ -1352,6 +1408,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1352
1408
|
successfulSendsAtTurnStart: 0,
|
|
1353
1409
|
inFlightToolSends: new Map(),
|
|
1354
1410
|
policyDeniedToolSendsThisTurn: new Map(),
|
|
1411
|
+
emptyTurnRetries: 0,
|
|
1355
1412
|
skippedTurn: null,
|
|
1356
1413
|
skipLockedSendTurn: null,
|
|
1357
1414
|
pendingQuoteCandidate: null,
|
|
@@ -1367,6 +1424,30 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1367
1424
|
}
|
|
1368
1425
|
live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
|
|
1369
1426
|
logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
|
|
1427
|
+
// A provider soft-error (rate/usage limit, billing, malformed response)
|
|
1428
|
+
// ends the turn with no assistant text, so the human otherwise sees
|
|
1429
|
+
// silence. Surface the REDACTED `safeMessage` (never the raw provider
|
|
1430
|
+
// text, which can carry response bodies / URLs / tokens) via a 'system'
|
|
1431
|
+
// send — the same one-shot bypass path validateChannelTurn uses, so it
|
|
1432
|
+
// lands regardless of per-turn send caps and skips the duplicate guard.
|
|
1433
|
+
void send(
|
|
1434
|
+
{
|
|
1435
|
+
adapter: live.key.adapter,
|
|
1436
|
+
workspace: live.key.workspace,
|
|
1437
|
+
chat: live.key.chat,
|
|
1438
|
+
thread: live.key.thread,
|
|
1439
|
+
text: `⚠️ ${err.safeMessage}`,
|
|
1440
|
+
},
|
|
1441
|
+
{ source: 'system' },
|
|
1442
|
+
)
|
|
1443
|
+
.then((result) => {
|
|
1444
|
+
if (!result.ok) {
|
|
1445
|
+
logger.warn(`[channels] ${live.keyId}: provider-error notice send failed: ${result.error}`)
|
|
1446
|
+
}
|
|
1447
|
+
})
|
|
1448
|
+
.catch((sendErr) => {
|
|
1449
|
+
logger.warn(`[channels] ${live.keyId}: provider-error notice send threw: ${describe(sendErr)}`)
|
|
1450
|
+
})
|
|
1370
1451
|
})
|
|
1371
1452
|
live.unsubTodoOutcome = created.session.subscribe((event: unknown) => {
|
|
1372
1453
|
const usage = extractTurnUsage(event)
|
|
@@ -1796,6 +1877,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1796
1877
|
live.consecutiveSends.clear()
|
|
1797
1878
|
live.lastSentText.clear()
|
|
1798
1879
|
live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
|
|
1880
|
+
// A real user batch starts a fresh logical turn → restore the full
|
|
1881
|
+
// empty-turn retry budget. Reset here (batch.length > 0) and NOT in
|
|
1882
|
+
// the per-prompt block below, so the reminder-only iterations the
|
|
1883
|
+
// retry itself queues do not refill the budget and loop forever.
|
|
1884
|
+
live.emptyTurnRetries = 0
|
|
1799
1885
|
} else if (live.lastTurnAuthorId !== null) {
|
|
1800
1886
|
live.currentTurnEngageReactions = []
|
|
1801
1887
|
// Reminder-only turn (batch.length === 0, reminders.length > 0):
|
|
@@ -2574,6 +2660,26 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2574
2660
|
)
|
|
2575
2661
|
}
|
|
2576
2662
|
|
|
2663
|
+
const registerReviewStateResolver = (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver): void => {
|
|
2664
|
+
reviewStateResolvers.set(adapter, resolver)
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
const unregisterReviewStateResolver = (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver): void => {
|
|
2668
|
+
if (reviewStateResolvers.get(adapter) === resolver) {
|
|
2669
|
+
reviewStateResolvers.delete(adapter)
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
const getReviewState = async (req: ReviewStateRequest): Promise<ReviewStateResult> => {
|
|
2674
|
+
const resolver = reviewStateResolvers.get(req.adapter)
|
|
2675
|
+
if (resolver === undefined) {
|
|
2676
|
+
return { ok: false, error: `adapter "${req.adapter}" does not support review-state lookup`, code: 'unsupported' }
|
|
2677
|
+
}
|
|
2678
|
+
return await resolver(req).catch(
|
|
2679
|
+
(err): ReviewStateResult => ({ ok: false, error: describe(err), code: 'transient' }),
|
|
2680
|
+
)
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2577
2683
|
const lookupInboundAttachment = (args: ChannelKey & { id: number }): InboundAttachment | null => {
|
|
2578
2684
|
const live = liveSessions.get(channelKeyId(args))
|
|
2579
2685
|
if (live === undefined) return null
|
|
@@ -2844,15 +2950,64 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2844
2950
|
}
|
|
2845
2951
|
if (live.successfulChannelSends > successfulSendsBeforePrompt) return
|
|
2846
2952
|
|
|
2953
|
+
const postEmptyTurnFallback = async (cause: string): Promise<void> => {
|
|
2954
|
+
logger.warn(`[channels] ${live.keyId} empty_turn_fallback cause=${cause}`)
|
|
2955
|
+
const result = await send(
|
|
2956
|
+
{
|
|
2957
|
+
adapter: live.key.adapter,
|
|
2958
|
+
workspace: live.key.workspace,
|
|
2959
|
+
chat: live.key.chat,
|
|
2960
|
+
thread: live.key.thread,
|
|
2961
|
+
text: EMPTY_TURN_FALLBACK_TEXT,
|
|
2962
|
+
},
|
|
2963
|
+
{ source: 'system' },
|
|
2964
|
+
)
|
|
2965
|
+
if (!result.ok) {
|
|
2966
|
+
logger.warn(`[channels] ${live.keyId}: empty-turn fallback send failed: ${result.error}`)
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2847
2970
|
const candidate = recoverableAssistantText(live.session)
|
|
2848
2971
|
if (candidate === null) {
|
|
2849
|
-
//
|
|
2850
|
-
//
|
|
2851
|
-
//
|
|
2852
|
-
//
|
|
2853
|
-
//
|
|
2854
|
-
//
|
|
2855
|
-
|
|
2972
|
+
// No recoverable assistant prose: the turn ended with no usable reply.
|
|
2973
|
+
// Two distinct shapes, handled differently (Option B):
|
|
2974
|
+
//
|
|
2975
|
+
// 1. The model THRASHED the send path this turn — it tried to send but
|
|
2976
|
+
// every attempt was denied (skip-locked, or policy-denied/duplicate/
|
|
2977
|
+
// cap, tracked on skipLockedSendTurn / policyDeniedToolSendsThisTurn).
|
|
2978
|
+
// Re-prompting would just re-thrash, so skip retry and post the
|
|
2979
|
+
// user-facing fallback once.
|
|
2980
|
+
//
|
|
2981
|
+
// 2. The PURE reasoning-loop — no send was ever attempted; the model
|
|
2982
|
+
// burned its budget thinking and produced nothing (the canonical
|
|
2983
|
+
// kimi `stopReason: 'length'` / `aborted` degeneration). Re-prompt up
|
|
2984
|
+
// to MAX_EMPTY_TURN_RETRIES with a neutral nudge; on exhaustion, fall
|
|
2985
|
+
// back. The nudge is injected as a reminder-only turn so drain()'s
|
|
2986
|
+
// while-loop re-runs session.prompt() against the same branch.
|
|
2987
|
+
//
|
|
2988
|
+
// The legitimate empty-state case (a TUI-only check before any user
|
|
2989
|
+
// prompt, no inbound this turn) is excluded: no batch means no real turn
|
|
2990
|
+
// to retry or apologize for — keep the historical silent bail there.
|
|
2991
|
+
const attemptedSendThisTurn =
|
|
2992
|
+
live.skipLockedSendTurn === live.turnSeq || live.policyDeniedToolSendsThisTurn.size > 0
|
|
2993
|
+
|
|
2994
|
+
// Only a TRUNCATED assistant leaf (length/error/aborted) from a real
|
|
2995
|
+
// conversational turn is a degeneration worth retrying. A cold/empty turn
|
|
2996
|
+
// (no inbound author, or no assistant message at all) keeps the historical
|
|
2997
|
+
// silent bail — re-prompting it would manufacture replies to nothing.
|
|
2998
|
+
if (live.currentTurnAuthorId === null || !assistantLeafTruncated(live.session)) {
|
|
2999
|
+
logger.info(`[channels] ${live.keyId}: no recoverable assistant text in branch`)
|
|
3000
|
+
return
|
|
3001
|
+
}
|
|
3002
|
+
if (!attemptedSendThisTurn && live.emptyTurnRetries < MAX_EMPTY_TURN_RETRIES) {
|
|
3003
|
+
live.emptyTurnRetries++
|
|
3004
|
+
logger.warn(
|
|
3005
|
+
`[channels] ${live.keyId} empty_turn_retry attempt=${live.emptyTurnRetries}/${MAX_EMPTY_TURN_RETRIES}`,
|
|
3006
|
+
)
|
|
3007
|
+
live.pendingSystemReminders.push(EMPTY_TURN_RETRY_NUDGE)
|
|
3008
|
+
return
|
|
3009
|
+
}
|
|
3010
|
+
await postEmptyTurnFallback(attemptedSendThisTurn ? 'send_thrash' : 'retries_exhausted')
|
|
2856
3011
|
return
|
|
2857
3012
|
}
|
|
2858
3013
|
|
|
@@ -3398,6 +3553,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3398
3553
|
registerReviewThreadResolver,
|
|
3399
3554
|
unregisterReviewThreadResolver,
|
|
3400
3555
|
resolveReviewThread,
|
|
3556
|
+
registerReviewStateResolver,
|
|
3557
|
+
unregisterReviewStateResolver,
|
|
3558
|
+
getReviewState,
|
|
3401
3559
|
lookupInboundAttachment,
|
|
3402
3560
|
listInboundAttachmentIds,
|
|
3403
3561
|
executeCommand,
|
|
@@ -4109,6 +4267,20 @@ function recoverableAssistantText(
|
|
|
4109
4267
|
return null
|
|
4110
4268
|
}
|
|
4111
4269
|
|
|
4270
|
+
// True only when the leaf is an assistant message that was CUT OFF mid-output:
|
|
4271
|
+
// `length` (hit the token cap — the canonical kimi reasoning-loop), `error`, or
|
|
4272
|
+
// `aborted`. This is the precise signature of "the model was producing but got
|
|
4273
|
+
// truncated", as distinct from a turn that produced no assistant message at all
|
|
4274
|
+
// (leaf undefined / a non-assistant entry), which is a benign empty/cold turn —
|
|
4275
|
+
// NOT something to re-prompt. The empty-turn retry guard keys off this so it
|
|
4276
|
+
// fires for real degenerations and stays silent for cold sessions.
|
|
4277
|
+
function assistantLeafTruncated(session: AgentSession): boolean {
|
|
4278
|
+
const leaf = session.sessionManager.getLeafEntry()
|
|
4279
|
+
if (!leaf || leaf.type !== 'message' || leaf.message.role !== 'assistant') return false
|
|
4280
|
+
const stop = leaf.message.stopReason
|
|
4281
|
+
return stop === 'length' || stop === 'error' || stop === 'aborted'
|
|
4282
|
+
}
|
|
4283
|
+
|
|
4112
4284
|
function visibleAssistantText(message: AssistantMessage): string {
|
|
4113
4285
|
return message.content
|
|
4114
4286
|
.filter((block) => block.type === 'text')
|
package/src/channels/types.ts
CHANGED
|
@@ -382,6 +382,37 @@ export type ReviewThreadResolveResult =
|
|
|
382
382
|
// support review threads never register one; the router answers `unsupported`.
|
|
383
383
|
export type ReviewThreadResolver = (req: ReviewThreadResolveRequest) => Promise<ReviewThreadResolveResult>
|
|
384
384
|
|
|
385
|
+
// A query for "does the bot still owe this PR a verdict?" — i.e. is the bot's
|
|
386
|
+
// latest formal review on the PR a sticky CHANGES_REQUESTED that no later
|
|
387
|
+
// APPROVE/dismissal has cleared. Used by the re-review stranding guard to stop
|
|
388
|
+
// the bot from resolving a thread / posting a close-out ack while it still
|
|
389
|
+
// holds a blocking review (the PR #644 failure: thread resolved + chat ack, but
|
|
390
|
+
// reviewDecision stuck at CHANGES_REQUESTED because neither carries review
|
|
391
|
+
// state). `workspace` is the repo slug `owner/name`; `chat` is `pr:<N>`.
|
|
392
|
+
export type ReviewStateRequest = {
|
|
393
|
+
adapter: AdapterId
|
|
394
|
+
workspace: string
|
|
395
|
+
chat: string
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// `selfBlocking` is the answer the guard acts on: true means the bot's latest
|
|
399
|
+
// effective formal review is its own CHANGES_REQUESTED (COMMENTED reviews are
|
|
400
|
+
// ignored — they never clear the sticky block, GitHub's own rule). `approve`
|
|
401
|
+
// mirrors `channels.github.review.approve` so the guard's denial text can tell
|
|
402
|
+
// the model whether to land a fresh APPROVE or to DISMISS its prior review.
|
|
403
|
+
//
|
|
404
|
+
// On `ok: false` the caller MUST fail closed: an unverifiable review state is
|
|
405
|
+
// treated like a live block, so the bot never claims close-out when the runtime
|
|
406
|
+
// could not confirm the platform-side verdict.
|
|
407
|
+
export type ReviewStateResult =
|
|
408
|
+
| { ok: true; selfBlocking: boolean; approve: boolean }
|
|
409
|
+
| { ok: false; error: string; code?: 'unsupported' | 'not-found' | 'permission-denied' | 'transient' }
|
|
410
|
+
|
|
411
|
+
// Registered per-adapter on the ChannelRouter, last-write-wins like the
|
|
412
|
+
// review-thread resolver. Adapters that never register one make `getReviewState`
|
|
413
|
+
// answer `unsupported`.
|
|
414
|
+
export type ReviewStateResolver = (req: ReviewStateRequest) => Promise<ReviewStateResult>
|
|
415
|
+
|
|
385
416
|
export function channelKeyId(key: { adapter: string; workspace: string; chat: string; thread: string | null }): string {
|
|
386
417
|
return `${key.adapter}:${key.workspace}:${key.chat}:${key.thread ?? ''}`
|
|
387
418
|
}
|
|
@@ -83,6 +83,7 @@ export function createTranscriptView(opts: TranscriptViewOptions) {
|
|
|
83
83
|
// transcripts; render per event once live.
|
|
84
84
|
let live = false
|
|
85
85
|
const onEvent = (event: InspectEvent): void => {
|
|
86
|
+
append(new Text(formatEventTime(event.ts), 0, 0))
|
|
86
87
|
append(componentFor(event))
|
|
87
88
|
if (live) tui.requestRender()
|
|
88
89
|
}
|
|
@@ -167,6 +168,15 @@ function compact(payload: unknown): string {
|
|
|
167
168
|
return s.length > 200 ? `${s.slice(0, 200)}…` : s
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
function formatEventTime(ts: number): string {
|
|
172
|
+
if (ts === 0) return colors.dim('--:--:--')
|
|
173
|
+
const d = new Date(ts)
|
|
174
|
+
const hh = String(d.getHours()).padStart(2, '0')
|
|
175
|
+
const mm = String(d.getMinutes()).padStart(2, '0')
|
|
176
|
+
const ss = String(d.getSeconds()).padStart(2, '0')
|
|
177
|
+
return colors.dim(`${hh}:${mm}:${ss}`)
|
|
178
|
+
}
|
|
179
|
+
|
|
170
180
|
function header(summary: SessionSummary): string {
|
|
171
181
|
const id = shortSessionId(summary.sessionId)
|
|
172
182
|
const label = summary.origin === null ? '(unknown origin)' : originLabel(summary.origin)
|
package/src/server/index.ts
CHANGED
|
@@ -199,8 +199,18 @@ export function safeWsSend(ws: { send: (data: string) => void }, msg: ServerMess
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
const TIMESTAMPED_SERVER_MESSAGES: ReadonlySet<ServerMessage['type']> = new Set([
|
|
203
|
+
'text_delta',
|
|
204
|
+
'tool_start',
|
|
205
|
+
'tool_end',
|
|
206
|
+
'done',
|
|
207
|
+
'error',
|
|
208
|
+
'prompt_started',
|
|
209
|
+
])
|
|
210
|
+
|
|
202
211
|
function send(ws: Ws, msg: ServerMessage): boolean {
|
|
203
|
-
|
|
212
|
+
const stamped = TIMESTAMPED_SERVER_MESSAGES.has(msg.type) ? { ...msg, ts: Date.now() } : msg
|
|
213
|
+
return safeWsSend(ws, stamped)
|
|
204
214
|
}
|
|
205
215
|
|
|
206
216
|
function sendTunnelLog(ws: TunnelLogsWs, msg: TunnelLogsServerMessage): boolean {
|
package/src/shared/protocol.ts
CHANGED
|
@@ -202,22 +202,34 @@ export type ClaimErrorPayload = {
|
|
|
202
202
|
reason: string
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// `ts` (ms since epoch) is the server send time, stamped centrally in `send()`,
|
|
206
|
+
// for the variants the TUI renders into scrollback. Optional on the wire so an
|
|
207
|
+
// old CLI parses a new server's frames; control frames the TUI never timestamps
|
|
208
|
+
// (queue_state, doctor, tunnel, claim, command_*) omit it by design.
|
|
205
209
|
export type ServerMessage =
|
|
206
210
|
// serverVersion is optional so an old CLI talking to a new server still
|
|
207
211
|
// parses cleanly. The server impl always emits it; consumers that care
|
|
208
212
|
// about host/agent skew (the TUI command in particular) read it to warn
|
|
209
213
|
// the user when their CLI is on a different version than the container.
|
|
210
214
|
| { type: 'connected'; sessionId: string; serverVersion?: string }
|
|
211
|
-
| { type: 'text_delta'; delta: string }
|
|
212
|
-
| { type: 'tool_start'; toolCallId: string; name: string; args: unknown }
|
|
213
|
-
| {
|
|
214
|
-
|
|
215
|
-
|
|
215
|
+
| { type: 'text_delta'; delta: string; ts?: number }
|
|
216
|
+
| { type: 'tool_start'; toolCallId: string; name: string; args: unknown; ts?: number }
|
|
217
|
+
| {
|
|
218
|
+
type: 'tool_end'
|
|
219
|
+
toolCallId: string
|
|
220
|
+
name: string
|
|
221
|
+
error: boolean
|
|
222
|
+
result: unknown
|
|
223
|
+
durationMs: number
|
|
224
|
+
ts?: number
|
|
225
|
+
}
|
|
226
|
+
| { type: 'done'; ts?: number }
|
|
227
|
+
| { type: 'error'; message: string; ts?: number }
|
|
216
228
|
| { type: 'reload_result'; results: ReloadResultPayload[] }
|
|
217
229
|
| { type: 'restart_result'; status: 'accepted' | 'failed'; message?: string; error?: string }
|
|
218
230
|
| { type: 'notification'; payload: unknown; replyTo?: string; meta?: Record<string, string> }
|
|
219
231
|
| { type: 'queue_state'; pending: QueueStateItem[] }
|
|
220
|
-
| { type: 'prompt_started'; messageId: string; text: string }
|
|
232
|
+
| { type: 'prompt_started'; messageId: string; text: string; ts?: number }
|
|
221
233
|
| { type: 'doctor_result'; requestId: DoctorRequestId; checks: DoctorCheckPayload[] }
|
|
222
234
|
| { type: 'doctor_fix_result'; requestId: DoctorRequestId; result: DoctorFixPayload }
|
|
223
235
|
| { type: 'cron_list_result'; requestId: CronListRequestId; result: CronListResultPayload }
|
package/src/tui/format.ts
CHANGED
|
@@ -24,6 +24,19 @@ export function formatUserPromptHistory(text: string): string {
|
|
|
24
24
|
.join('\n')
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export function formatTimestamp(ts: number | undefined): string {
|
|
28
|
+
if (ts === undefined || ts === 0) return colors.dim('--:--:--')
|
|
29
|
+
const d = new Date(ts)
|
|
30
|
+
const hh = String(d.getHours()).padStart(2, '0')
|
|
31
|
+
const mm = String(d.getMinutes()).padStart(2, '0')
|
|
32
|
+
const ss = String(d.getSeconds()).padStart(2, '0')
|
|
33
|
+
return colors.dim(`${hh}:${mm}:${ss}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function withTimestamp(ts: number | undefined, body: string): string {
|
|
37
|
+
return `${formatTimestamp(ts)} ${body}`
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
function stripHiddenBlocks(text: string): string {
|
|
28
41
|
return text.replace(/<hatching>[\s\S]*?<\/hatching>\s*/g, '').trimStart()
|
|
29
42
|
}
|
package/src/tui/index.ts
CHANGED
|
@@ -3,7 +3,14 @@ import { Editor, Key, Markdown, matchesKey, ProcessTerminal, type Terminal, Text
|
|
|
3
3
|
import { parseCommand } from '@/commands'
|
|
4
4
|
|
|
5
5
|
import { createClient as createClientDefault, type Client } from './client'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
formatQueuePanel,
|
|
8
|
+
formatTimestamp,
|
|
9
|
+
formatToolEnd,
|
|
10
|
+
formatToolStart,
|
|
11
|
+
formatUserPromptHistory,
|
|
12
|
+
withTimestamp,
|
|
13
|
+
} from './format'
|
|
7
14
|
import { colors, editorTheme, markdownTheme } from './theme'
|
|
8
15
|
|
|
9
16
|
export type ClientFactory = (url: string) => Promise<Client>
|
|
@@ -173,8 +180,13 @@ export function createTui({
|
|
|
173
180
|
onReplyDone = null
|
|
174
181
|
}
|
|
175
182
|
|
|
176
|
-
|
|
183
|
+
// A Markdown block can't carry an ANSI timestamp prefix (it'd be parsed as
|
|
184
|
+
// markdown), so the assistant turn's timestamp is a separate dim Text line
|
|
185
|
+
// emitted just above the block when it's first created — stamped with the
|
|
186
|
+
// first delta's server `ts`.
|
|
187
|
+
const ensureAssistantBlock = (ts: number | undefined): Markdown => {
|
|
177
188
|
if (currentAssistant) return currentAssistant
|
|
189
|
+
appendHistory(new Text(formatTimestamp(ts), 0, 0))
|
|
178
190
|
const md = new Markdown('', 0, 0, markdownTheme)
|
|
179
191
|
currentAssistant = md
|
|
180
192
|
currentAssistantText = ''
|
|
@@ -185,12 +197,12 @@ export function createTui({
|
|
|
185
197
|
client.onMessage((msg) => {
|
|
186
198
|
switch (msg.type) {
|
|
187
199
|
case 'prompt_started': {
|
|
188
|
-
appendHistory(new Text(formatUserPromptHistory(msg.text), 0, 0))
|
|
200
|
+
appendHistory(new Text(withTimestamp(msg.ts, formatUserPromptHistory(msg.text)), 0, 0))
|
|
189
201
|
tui.requestRender()
|
|
190
202
|
break
|
|
191
203
|
}
|
|
192
204
|
case 'text_delta': {
|
|
193
|
-
const block = ensureAssistantBlock()
|
|
205
|
+
const block = ensureAssistantBlock(msg.ts)
|
|
194
206
|
currentAssistantText += msg.delta
|
|
195
207
|
block.setText(currentAssistantText)
|
|
196
208
|
tui.requestRender()
|
|
@@ -198,13 +210,15 @@ export function createTui({
|
|
|
198
210
|
}
|
|
199
211
|
case 'tool_start': {
|
|
200
212
|
sealAssistantBlock()
|
|
201
|
-
appendHistory(new Text(formatToolStart(msg.name, msg.args), 0, 0))
|
|
213
|
+
appendHistory(new Text(withTimestamp(msg.ts, formatToolStart(msg.name, msg.args)), 0, 0))
|
|
202
214
|
tui.requestRender()
|
|
203
215
|
break
|
|
204
216
|
}
|
|
205
217
|
case 'tool_end': {
|
|
206
218
|
sealAssistantBlock()
|
|
207
|
-
appendHistory(
|
|
219
|
+
appendHistory(
|
|
220
|
+
new Text(withTimestamp(msg.ts, formatToolEnd(msg.name, msg.error, msg.result, msg.durationMs)), 0, 0),
|
|
221
|
+
)
|
|
208
222
|
tui.requestRender()
|
|
209
223
|
break
|
|
210
224
|
}
|
|
@@ -214,7 +228,7 @@ export function createTui({
|
|
|
214
228
|
break
|
|
215
229
|
}
|
|
216
230
|
case 'error': {
|
|
217
|
-
appendHistory(new Text(colors.red(`error: ${msg.message}`), 0, 0))
|
|
231
|
+
appendHistory(new Text(withTimestamp(msg.ts, colors.red(`error: ${msg.message}`)), 0, 0))
|
|
218
232
|
finishAssistantTurn()
|
|
219
233
|
tui.requestRender()
|
|
220
234
|
break
|