typeclaw 0.28.0 → 0.28.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.
- package/package.json +1 -1
- package/src/agent/provider-error.ts +33 -1
- package/src/agent/tools/channel-reply.ts +23 -0
- package/src/agent/tools/channel-send.ts +22 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +3 -3
- package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
- package/src/channels/adapters/github/inbound.ts +7 -6
- package/src/channels/adapters/github/index.ts +46 -11
- package/src/channels/adapters/github/reconcile-open-prs.ts +306 -0
- package/src/channels/adapters/github/review-state.ts +206 -0
- package/src/channels/github-rereview-guard.ts +100 -0
- package/src/channels/github-review-claim.ts +58 -10
- package/src/channels/router.ts +191 -7
- package/src/channels/schema.ts +3 -2
- package/src/channels/types.ts +36 -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/skills/typeclaw-channel-github/SKILL.md +1 -1
- package/src/tui/format.ts +13 -0
- package/src/tui/index.ts +21 -7
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,
|
|
@@ -1365,8 +1422,44 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1365
1422
|
unsubTypingActivity: null,
|
|
1366
1423
|
unsubTodoOutcome: null,
|
|
1367
1424
|
}
|
|
1425
|
+
// Tracks the `turnSeq` a provider-error notice was last POSTED for, so the
|
|
1426
|
+
// channel surfaces at most one notice per turn. The upstream SDK retries
|
|
1427
|
+
// internally, and each retry emits its own `message_end` with
|
|
1428
|
+
// `stopReason: 'error'` — without this gate a single failing turn posts N
|
|
1429
|
+
// identical "⚠️ upstream provider failed" notices (one per retry). Logs
|
|
1430
|
+
// still record every attempt; only the user-facing notice is deduped.
|
|
1431
|
+
let lastProviderErrorNoticeTurn: number | undefined
|
|
1368
1432
|
live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
|
|
1369
1433
|
logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
|
|
1434
|
+
// Suppress duplicate notices for the SAME turn (retry storm). Set the
|
|
1435
|
+
// marker BEFORE the async send so a synchronous burst of retry events
|
|
1436
|
+
// can't each slip past the check and enqueue their own notice.
|
|
1437
|
+
if (lastProviderErrorNoticeTurn === live.turnSeq) return
|
|
1438
|
+
lastProviderErrorNoticeTurn = live.turnSeq
|
|
1439
|
+
// A provider soft-error (rate/usage limit, billing, malformed response)
|
|
1440
|
+
// ends the turn with no assistant text, so the human otherwise sees
|
|
1441
|
+
// silence. Surface the REDACTED `safeMessage` (never the raw provider
|
|
1442
|
+
// text, which can carry response bodies / URLs / tokens) via a 'system'
|
|
1443
|
+
// send — the same one-shot bypass path validateChannelTurn uses, so it
|
|
1444
|
+
// lands regardless of per-turn send caps and skips the duplicate guard.
|
|
1445
|
+
void send(
|
|
1446
|
+
{
|
|
1447
|
+
adapter: live.key.adapter,
|
|
1448
|
+
workspace: live.key.workspace,
|
|
1449
|
+
chat: live.key.chat,
|
|
1450
|
+
thread: live.key.thread,
|
|
1451
|
+
text: `⚠️ ${err.safeMessage}`,
|
|
1452
|
+
},
|
|
1453
|
+
{ source: 'system' },
|
|
1454
|
+
)
|
|
1455
|
+
.then((result) => {
|
|
1456
|
+
if (!result.ok) {
|
|
1457
|
+
logger.warn(`[channels] ${live.keyId}: provider-error notice send failed: ${result.error}`)
|
|
1458
|
+
}
|
|
1459
|
+
})
|
|
1460
|
+
.catch((sendErr) => {
|
|
1461
|
+
logger.warn(`[channels] ${live.keyId}: provider-error notice send threw: ${describe(sendErr)}`)
|
|
1462
|
+
})
|
|
1370
1463
|
})
|
|
1371
1464
|
live.unsubTodoOutcome = created.session.subscribe((event: unknown) => {
|
|
1372
1465
|
const usage = extractTurnUsage(event)
|
|
@@ -1796,6 +1889,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1796
1889
|
live.consecutiveSends.clear()
|
|
1797
1890
|
live.lastSentText.clear()
|
|
1798
1891
|
live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
|
|
1892
|
+
// A real user batch starts a fresh logical turn → restore the full
|
|
1893
|
+
// empty-turn retry budget. Reset here (batch.length > 0) and NOT in
|
|
1894
|
+
// the per-prompt block below, so the reminder-only iterations the
|
|
1895
|
+
// retry itself queues do not refill the budget and loop forever.
|
|
1896
|
+
live.emptyTurnRetries = 0
|
|
1799
1897
|
} else if (live.lastTurnAuthorId !== null) {
|
|
1800
1898
|
live.currentTurnEngageReactions = []
|
|
1801
1899
|
// Reminder-only turn (batch.length === 0, reminders.length > 0):
|
|
@@ -2574,6 +2672,26 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2574
2672
|
)
|
|
2575
2673
|
}
|
|
2576
2674
|
|
|
2675
|
+
const registerReviewStateResolver = (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver): void => {
|
|
2676
|
+
reviewStateResolvers.set(adapter, resolver)
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
const unregisterReviewStateResolver = (adapter: ChannelKey['adapter'], resolver: ReviewStateResolver): void => {
|
|
2680
|
+
if (reviewStateResolvers.get(adapter) === resolver) {
|
|
2681
|
+
reviewStateResolvers.delete(adapter)
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
const getReviewState = async (req: ReviewStateRequest): Promise<ReviewStateResult> => {
|
|
2686
|
+
const resolver = reviewStateResolvers.get(req.adapter)
|
|
2687
|
+
if (resolver === undefined) {
|
|
2688
|
+
return { ok: false, error: `adapter "${req.adapter}" does not support review-state lookup`, code: 'unsupported' }
|
|
2689
|
+
}
|
|
2690
|
+
return await resolver(req).catch(
|
|
2691
|
+
(err): ReviewStateResult => ({ ok: false, error: describe(err), code: 'transient' }),
|
|
2692
|
+
)
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2577
2695
|
const lookupInboundAttachment = (args: ChannelKey & { id: number }): InboundAttachment | null => {
|
|
2578
2696
|
const live = liveSessions.get(channelKeyId(args))
|
|
2579
2697
|
if (live === undefined) return null
|
|
@@ -2844,15 +2962,64 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2844
2962
|
}
|
|
2845
2963
|
if (live.successfulChannelSends > successfulSendsBeforePrompt) return
|
|
2846
2964
|
|
|
2965
|
+
const postEmptyTurnFallback = async (cause: string): Promise<void> => {
|
|
2966
|
+
logger.warn(`[channels] ${live.keyId} empty_turn_fallback cause=${cause}`)
|
|
2967
|
+
const result = await send(
|
|
2968
|
+
{
|
|
2969
|
+
adapter: live.key.adapter,
|
|
2970
|
+
workspace: live.key.workspace,
|
|
2971
|
+
chat: live.key.chat,
|
|
2972
|
+
thread: live.key.thread,
|
|
2973
|
+
text: EMPTY_TURN_FALLBACK_TEXT,
|
|
2974
|
+
},
|
|
2975
|
+
{ source: 'system' },
|
|
2976
|
+
)
|
|
2977
|
+
if (!result.ok) {
|
|
2978
|
+
logger.warn(`[channels] ${live.keyId}: empty-turn fallback send failed: ${result.error}`)
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2847
2982
|
const candidate = recoverableAssistantText(live.session)
|
|
2848
2983
|
if (candidate === null) {
|
|
2849
|
-
//
|
|
2850
|
-
//
|
|
2851
|
-
//
|
|
2852
|
-
//
|
|
2853
|
-
//
|
|
2854
|
-
//
|
|
2855
|
-
|
|
2984
|
+
// No recoverable assistant prose: the turn ended with no usable reply.
|
|
2985
|
+
// Two distinct shapes, handled differently (Option B):
|
|
2986
|
+
//
|
|
2987
|
+
// 1. The model THRASHED the send path this turn — it tried to send but
|
|
2988
|
+
// every attempt was denied (skip-locked, or policy-denied/duplicate/
|
|
2989
|
+
// cap, tracked on skipLockedSendTurn / policyDeniedToolSendsThisTurn).
|
|
2990
|
+
// Re-prompting would just re-thrash, so skip retry and post the
|
|
2991
|
+
// user-facing fallback once.
|
|
2992
|
+
//
|
|
2993
|
+
// 2. The PURE reasoning-loop — no send was ever attempted; the model
|
|
2994
|
+
// burned its budget thinking and produced nothing (the canonical
|
|
2995
|
+
// kimi `stopReason: 'length'` / `aborted` degeneration). Re-prompt up
|
|
2996
|
+
// to MAX_EMPTY_TURN_RETRIES with a neutral nudge; on exhaustion, fall
|
|
2997
|
+
// back. The nudge is injected as a reminder-only turn so drain()'s
|
|
2998
|
+
// while-loop re-runs session.prompt() against the same branch.
|
|
2999
|
+
//
|
|
3000
|
+
// The legitimate empty-state case (a TUI-only check before any user
|
|
3001
|
+
// prompt, no inbound this turn) is excluded: no batch means no real turn
|
|
3002
|
+
// to retry or apologize for — keep the historical silent bail there.
|
|
3003
|
+
const attemptedSendThisTurn =
|
|
3004
|
+
live.skipLockedSendTurn === live.turnSeq || live.policyDeniedToolSendsThisTurn.size > 0
|
|
3005
|
+
|
|
3006
|
+
// Only a TRUNCATED assistant leaf (length/error/aborted) from a real
|
|
3007
|
+
// conversational turn is a degeneration worth retrying. A cold/empty turn
|
|
3008
|
+
// (no inbound author, or no assistant message at all) keeps the historical
|
|
3009
|
+
// silent bail — re-prompting it would manufacture replies to nothing.
|
|
3010
|
+
if (live.currentTurnAuthorId === null || !assistantLeafTruncated(live.session)) {
|
|
3011
|
+
logger.info(`[channels] ${live.keyId}: no recoverable assistant text in branch`)
|
|
3012
|
+
return
|
|
3013
|
+
}
|
|
3014
|
+
if (!attemptedSendThisTurn && live.emptyTurnRetries < MAX_EMPTY_TURN_RETRIES) {
|
|
3015
|
+
live.emptyTurnRetries++
|
|
3016
|
+
logger.warn(
|
|
3017
|
+
`[channels] ${live.keyId} empty_turn_retry attempt=${live.emptyTurnRetries}/${MAX_EMPTY_TURN_RETRIES}`,
|
|
3018
|
+
)
|
|
3019
|
+
live.pendingSystemReminders.push(EMPTY_TURN_RETRY_NUDGE)
|
|
3020
|
+
return
|
|
3021
|
+
}
|
|
3022
|
+
await postEmptyTurnFallback(attemptedSendThisTurn ? 'send_thrash' : 'retries_exhausted')
|
|
2856
3023
|
return
|
|
2857
3024
|
}
|
|
2858
3025
|
|
|
@@ -3398,6 +3565,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3398
3565
|
registerReviewThreadResolver,
|
|
3399
3566
|
unregisterReviewThreadResolver,
|
|
3400
3567
|
resolveReviewThread,
|
|
3568
|
+
registerReviewStateResolver,
|
|
3569
|
+
unregisterReviewStateResolver,
|
|
3570
|
+
getReviewState,
|
|
3401
3571
|
lookupInboundAttachment,
|
|
3402
3572
|
listInboundAttachmentIds,
|
|
3403
3573
|
executeCommand,
|
|
@@ -4109,6 +4279,20 @@ function recoverableAssistantText(
|
|
|
4109
4279
|
return null
|
|
4110
4280
|
}
|
|
4111
4281
|
|
|
4282
|
+
// True only when the leaf is an assistant message that was CUT OFF mid-output:
|
|
4283
|
+
// `length` (hit the token cap — the canonical kimi reasoning-loop), `error`, or
|
|
4284
|
+
// `aborted`. This is the precise signature of "the model was producing but got
|
|
4285
|
+
// truncated", as distinct from a turn that produced no assistant message at all
|
|
4286
|
+
// (leaf undefined / a non-assistant entry), which is a benign empty/cold turn —
|
|
4287
|
+
// NOT something to re-prompt. The empty-turn retry guard keys off this so it
|
|
4288
|
+
// fires for real degenerations and stays silent for cold sessions.
|
|
4289
|
+
function assistantLeafTruncated(session: AgentSession): boolean {
|
|
4290
|
+
const leaf = session.sessionManager.getLeafEntry()
|
|
4291
|
+
if (!leaf || leaf.type !== 'message' || leaf.message.role !== 'assistant') return false
|
|
4292
|
+
const stop = leaf.message.stopReason
|
|
4293
|
+
return stop === 'length' || stop === 'error' || stop === 'aborted'
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4112
4296
|
function visibleAssistantText(message: AssistantMessage): string {
|
|
4113
4297
|
return message.content
|
|
4114
4298
|
.filter((block) => block.type === 'text')
|
package/src/channels/schema.ts
CHANGED
|
@@ -192,8 +192,9 @@ export const SEEDED_GITHUB_EVENT_ALLOWLISTS: readonly (readonly string[])[] = [
|
|
|
192
192
|
// prefix is implied by this field living under the review config); `off` is the
|
|
193
193
|
// disable sentinel, matching the `engagement.stickiness: 'off'` convention:
|
|
194
194
|
// - 'review_requested' — review only when the bot is requested (default)
|
|
195
|
-
// - 'opened' — review every non-draft PR as soon as it opens; draft
|
|
196
|
-
//
|
|
195
|
+
// - 'opened' — review every non-draft PR as soon as it opens; a draft
|
|
196
|
+
// PR wakes no session and is reviewed once it turns ready
|
|
197
|
+
// (ready_for_review) or the bot is explicitly requested
|
|
197
198
|
// - 'off' — disable code review entirely
|
|
198
199
|
export const GITHUB_REVIEW_ON_VALUES = ['review_requested', 'opened', 'off'] as const
|
|
199
200
|
|
package/src/channels/types.ts
CHANGED
|
@@ -382,6 +382,42 @@ 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 for re-reviews: true means the
|
|
399
|
+
// bot's latest effective formal review is its own CHANGES_REQUESTED (COMMENTED
|
|
400
|
+
// reviews are ignored — they never clear the sticky block, GitHub's own rule).
|
|
401
|
+
// `reviewDecision` is GitHub's aggregate PR review status when GraphQL can
|
|
402
|
+
// provide it; REVIEW_REQUIRED means an approval-shaped flat comment would still
|
|
403
|
+
// leave the PR awaiting a formal review. `approve` mirrors
|
|
404
|
+
// `channels.github.review.approve` so the guard's denial text can tell the model
|
|
405
|
+
// whether to land a fresh APPROVE or to DISMISS its prior review.
|
|
406
|
+
//
|
|
407
|
+
// On `ok: false` the caller MUST fail closed: an unverifiable review state is
|
|
408
|
+
// treated like a live block, so the bot never claims close-out when the runtime
|
|
409
|
+
// could not confirm the platform-side verdict.
|
|
410
|
+
export type ReviewStateResult =
|
|
411
|
+
| { ok: true; selfBlocking: boolean; approve: boolean; reviewDecision?: GithubReviewDecision }
|
|
412
|
+
| { ok: false; error: string; code?: 'unsupported' | 'not-found' | 'permission-denied' | 'transient' }
|
|
413
|
+
|
|
414
|
+
export type GithubReviewDecision = 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED'
|
|
415
|
+
|
|
416
|
+
// Registered per-adapter on the ChannelRouter, last-write-wins like the
|
|
417
|
+
// review-thread resolver. Adapters that never register one make `getReviewState`
|
|
418
|
+
// answer `unsupported`.
|
|
419
|
+
export type ReviewStateResolver = (req: ReviewStateRequest) => Promise<ReviewStateResult>
|
|
420
|
+
|
|
385
421
|
export function channelKeyId(key: { adapter: string; workspace: string; chat: string; thread: string | null }): string {
|
|
386
422
|
return `${key.adapter}:${key.workspace}:${key.chat}:${key.thread ?? ''}`
|
|
387
423
|
}
|
|
@@ -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 }
|
|
@@ -189,7 +189,7 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
|
|
|
189
189
|
|
|
190
190
|
A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`. The inline-review post in step 4 applies whenever the actionable count is **at least one**. When the reviewer returns **exactly zero** actionable findings (only `praise`, or none), there is nothing to anchor inline — handle by verdict:
|
|
191
191
|
|
|
192
|
-
- `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array). **This is still a formal review via `POST /pulls/<N>/reviews`, NOT a `channel_reply`.** A zero-findings approval is the single most common place this goes wrong: with nothing to anchor inline, the model is tempted to just `channel_reply({ text: "Approved …" })` and end the turn. That posts a plain PR comment and leaves the PR **"awaiting review"** with no approval — the verdict never reaches GitHub's review API. Never start a top-level `channel_reply` on a `pr:N` with "Approved" / "LGTM" / "Request changes": those are verdicts, and verdicts are always formal reviews. Submit the `APPROVE` via `gh api`, confirm it landed (step 5), then `skip_response`. **If the operator approval policy above disabled approval, submit a `COMMENT` review instead — same `<summary>` as the review body, `event: "COMMENT"`, no `comments[]` array. Keep it a formal review, not a top-level issue comment, so the review metadata and flow are preserved.** (Re-review caveat: a `COMMENT` review does **not** clear a sticky `CHANGES_REQUESTED` block. If this is a re-review under approval-disabled policy, follow the step-4 re-review branch — dismiss your prior review — instead of relying on this `COMMENT`.)
|
|
192
|
+
- `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array). **Post the `<summary>` verbatim — do not pad it back into a play-by-play.** The reviewer's contract already makes the summary a terse, author-facing verdict justification (no process narration, no "I loaded the X skill", no recap of what the PR does); your job is to forward it, not re-expand it. **This is still a formal review via `POST /pulls/<N>/reviews`, NOT a `channel_reply`.** A zero-findings approval is the single most common place this goes wrong: with nothing to anchor inline, the model is tempted to just `channel_reply({ text: "Approved …" })` and end the turn. That posts a plain PR comment and leaves the PR **"awaiting review"** with no approval — the verdict never reaches GitHub's review API. Never start a top-level `channel_reply` on a `pr:N` with "Approved" / "LGTM" / "Request changes": those are verdicts, and verdicts are always formal reviews. Submit the `APPROVE` via `gh api`, confirm it landed (step 5), then `skip_response`. **If the operator approval policy above disabled approval, submit a `COMMENT` review instead — same `<summary>` as the review body, `event: "COMMENT"`, no `comments[]` array. Keep it a formal review, not a top-level issue comment, so the review metadata and flow are preserved.** (Re-review caveat: a `COMMENT` review does **not** clear a sticky `CHANGES_REQUESTED` block. If this is a re-review under approval-disabled policy, follow the step-4 re-review branch — dismiss your prior review — instead of relying on this `COMMENT`.)
|
|
193
193
|
- `comment` → post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments` instead of submitting an empty review. **Exception — re-reviews:** if this is a re-review (you have an unresolved blocking obligation — a formal `CHANGES_REQUESTED` **or** an unretracted flat-comment blocker), a top-level comment discharges neither. Do not use this branch; resolve it via the step-4 re-review branch (`APPROVE` if resolved and approval is enabled, the dismissal endpoint if a formal block is resolved but approval is disabled, `REQUEST_CHANGES` if not resolved).
|
|
194
194
|
- `request-changes` → submit `REQUEST_CHANGES` with the `<summary>` as the review body and no `comments[]` array. This combination is rare (the reviewer's contract says `request-changes` requires at least one blocker or load-bearing concern); if it happens, faithfully encode the verdict and trust the reviewer's reasoning is in the summary.
|
|
195
195
|
|
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
|