@rubytech/create-maxy-code 0.1.242 → 0.1.243
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/payload/platform/docs/superpowers/plans/2026-06-02-task-610-follower-202-retry.md +372 -0
- package/payload/platform/docs/superpowers/specs/2026-06-02-task-610-follower-202-retry-design.md +116 -0
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +3 -1
- package/payload/platform/plugins/docs/references/internals.md +2 -0
- package/payload/server/server.js +34 -4
package/package.json
CHANGED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
# Follower 202 Cold-Start Retry Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Make the channel-pty-bridge JSONL follower survive the cold-spawn `202 {pending:true}` window so a fresh public webchat turn detects `end_turn` and the visitor receives the greeting instead of a 120s timeout.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Wrap the follower's single `fetch(/log?follow=1)` in a bounded retry loop. `202` triggers a logged retry on a poll interval until a `200` stream arrives or a give-up window elapses; `200` proceeds into the existing read/fan-out loop unchanged; other non-ok statuses keep today's `onError`+`onClose`. Two env vars (give-up window, poll interval) tune the loop, defaulting to cover claude cold-boot. The manager is untouched.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, Node fetch + ReadableStream, vitest. Files in `maxy-code/platform/ui/app/lib/channel-pty-bridge/`.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
### Task 1: Extend the test fetch helper to script a follow-response sequence
|
|
14
|
+
|
|
15
|
+
The current `installManagerFetch` always answers `/log?follow=1` with a `200` stream. To
|
|
16
|
+
test the `202`→`200` path we need to script per-sessionId follow responses: a list of
|
|
17
|
+
statuses where `202` returns the pending JSON body and `200` returns the stream.
|
|
18
|
+
|
|
19
|
+
**Files:**
|
|
20
|
+
- Modify: `maxy-code/platform/ui/app/lib/channel-pty-bridge/__tests__/helpers.ts`
|
|
21
|
+
|
|
22
|
+
- [ ] **Step 1: Add a `followStatuses` option and apply it in the follow branch**
|
|
23
|
+
|
|
24
|
+
In `ManagerFetchOptions`, add:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
/** Per-sessionId status sequence for /:id/log?follow=1. Each 202 returns
|
|
28
|
+
* `{pending:true}` and consumes one entry; the first non-202 entry returns
|
|
29
|
+
* the stream (200) or an error body. Cycles the last entry. When unset, the
|
|
30
|
+
* follow branch returns 200 + stream as before. */
|
|
31
|
+
followStatuses?: Record<string, number[]>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
In `installManagerFetch`, before the `fetchMock` definition:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
const followStatusSeq = opts.followStatuses ?? {}
|
|
38
|
+
const followStatusIdx: Record<string, number> = {}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Replace the existing follow branch body (the `if (followMatch && method === 'GET')` block)
|
|
42
|
+
with:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
const followMatch = url.match(/\/([^/?]+)\/log\?follow=1$/)
|
|
46
|
+
if (followMatch && method === 'GET') {
|
|
47
|
+
const sessionId = followMatch[1]
|
|
48
|
+
followCalls.push(sessionId)
|
|
49
|
+
const seq = followStatusSeq[sessionId]
|
|
50
|
+
if (seq && seq.length > 0) {
|
|
51
|
+
const i = Math.min(followStatusIdx[sessionId] ?? 0, seq.length - 1)
|
|
52
|
+
followStatusIdx[sessionId] = (followStatusIdx[sessionId] ?? 0) + 1
|
|
53
|
+
const status = seq[i]
|
|
54
|
+
if (status === 202) {
|
|
55
|
+
return new Response(JSON.stringify({ jsonlPath: null, exists: false, sizeBytes: 0, pending: true }), {
|
|
56
|
+
status: 202,
|
|
57
|
+
headers: { 'content-type': 'application/json' },
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const stream = perId[sessionId] ?? defaultFollow ?? makeJsonlStream()
|
|
62
|
+
return new Response(stream.body, {
|
|
63
|
+
status: 200,
|
|
64
|
+
headers: { 'content-type': 'application/x-ndjson' },
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- [ ] **Step 2: Confirm the existing follower suite still compiles and passes**
|
|
70
|
+
|
|
71
|
+
Run: `cd maxy-code/platform && npx vitest run ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts`
|
|
72
|
+
Expected: PASS (4 tests) — the helper change is additive; `followStatuses` is unset in
|
|
73
|
+
every existing test so the follow branch behaves exactly as before.
|
|
74
|
+
|
|
75
|
+
- [ ] **Step 3: Commit**
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
git add maxy-code/platform/ui/app/lib/channel-pty-bridge/__tests__/helpers.ts
|
|
79
|
+
git commit -m "test(610): scriptable follow-response sequence in manager fetch helper"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
### Task 2: Failing test — follower retries on 202 then fans out on the 200 stream
|
|
85
|
+
|
|
86
|
+
**Files:**
|
|
87
|
+
- Modify: `maxy-code/platform/ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts`
|
|
88
|
+
|
|
89
|
+
- [ ] **Step 1: Write the failing test**
|
|
90
|
+
|
|
91
|
+
Add inside the `describe('follower', …)` block (the `makeEntry` helper and imports already
|
|
92
|
+
exist at the top of the file):
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
it('retries on 202 pending then fans out once the 200 stream is available', async () => {
|
|
96
|
+
const stream = makeJsonlStream()
|
|
97
|
+
// First follow fetch → 202 pending; second → 200 + stream.
|
|
98
|
+
mgr = installManagerFetch({
|
|
99
|
+
followStream: stream,
|
|
100
|
+
followStatuses: { 'sess-aaaaaaaa': [202, 200] },
|
|
101
|
+
})
|
|
102
|
+
process.env.CHANNEL_PTY_FOLLOWER_RETRY_MS = '5'
|
|
103
|
+
process.env.CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS = '5000'
|
|
104
|
+
const entry = makeEntry()
|
|
105
|
+
const received: string[] = []
|
|
106
|
+
entry.subscribers.add((text) => {
|
|
107
|
+
received.push(text)
|
|
108
|
+
})
|
|
109
|
+
startFollower({ entry, tag: '[test]', onClose: () => {} })
|
|
110
|
+
// Wait until the retry loop has issued the second (200) follow fetch.
|
|
111
|
+
await waitFor(() => mgr.followCalls.length >= 2)
|
|
112
|
+
stream.push(
|
|
113
|
+
JSON.stringify({
|
|
114
|
+
type: 'assistant',
|
|
115
|
+
message: { content: [{ type: 'text', text: 'greeting' }], stop_reason: 'end_turn' },
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
await waitFor(() => received.length > 0)
|
|
119
|
+
expect(received).toEqual(['greeting'])
|
|
120
|
+
})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Add to the `afterEach` (or a dedicated cleanup) so the env vars don't leak into other
|
|
124
|
+
tests — place these two lines in the existing `afterEach` callback:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
delete process.env.CHANNEL_PTY_FOLLOWER_RETRY_MS
|
|
128
|
+
delete process.env.CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
132
|
+
|
|
133
|
+
Run: `cd maxy-code/platform && npx vitest run ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts -t "retries on 202"`
|
|
134
|
+
Expected: FAIL — today the follower treats `202` as success, consumes the pending JSON
|
|
135
|
+
body as a single non-event line, reads `done`, and closes. `mgr.followCalls.length` never
|
|
136
|
+
reaches 2 (no retry), so `waitFor` throws "predicate never became true".
|
|
137
|
+
|
|
138
|
+
- [ ] **Step 3: Commit the failing test**
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
git add maxy-code/platform/ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts
|
|
142
|
+
git commit -m "test(610): failing test for follower 202 retry then fan-out"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
### Task 3: Implement the bounded 202 retry loop in the follower
|
|
148
|
+
|
|
149
|
+
**Files:**
|
|
150
|
+
- Modify: `maxy-code/platform/ui/app/lib/channel-pty-bridge/follower.ts`
|
|
151
|
+
|
|
152
|
+
- [ ] **Step 1: Add the two config readers**
|
|
153
|
+
|
|
154
|
+
Below the imports (after the `type JsonlEvent = …` line, before `FollowerOptions`), add:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
function followerPendingMaxMs(): number {
|
|
158
|
+
return Number(process.env.CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS ?? String(120_000))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function followerRetryMs(): number {
|
|
162
|
+
return Number(process.env.CHANNEL_PTY_FOLLOWER_RETRY_MS ?? String(1_000))
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- [ ] **Step 2: Replace the single-fetch guard with the retry loop**
|
|
167
|
+
|
|
168
|
+
Replace this block (currently `follower.ts:42-51`):
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
try {
|
|
172
|
+
const res = await fetch(managerLogFollowUrl(entry.sessionId), {
|
|
173
|
+
signal: abort.signal,
|
|
174
|
+
})
|
|
175
|
+
if (!res.ok || !res.body) {
|
|
176
|
+
opts.onError?.(`follow-status-${res.status}`)
|
|
177
|
+
opts.onClose()
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
const reader = res.body.getReader()
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
with:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
try {
|
|
187
|
+
const sid = entry.sessionId.slice(0, 8)
|
|
188
|
+
const deadline = Date.now() + followerPendingMaxMs()
|
|
189
|
+
let res: Response
|
|
190
|
+
let attempt = 0
|
|
191
|
+
for (;;) {
|
|
192
|
+
res = await fetch(managerLogFollowUrl(entry.sessionId), {
|
|
193
|
+
signal: abort.signal,
|
|
194
|
+
})
|
|
195
|
+
console.error(`${tag} follower-connect sessionId=${sid} status=${res.status}`)
|
|
196
|
+
// 202 {pending:true}: the PTY is live but claude has not flushed its
|
|
197
|
+
// first JSONL line yet (cold-spawn window). Retry on a poll interval
|
|
198
|
+
// until a 200 stream is available or the give-up window elapses.
|
|
199
|
+
if (res.status === 202) {
|
|
200
|
+
attempt += 1
|
|
201
|
+
await res.body?.cancel().catch(() => {})
|
|
202
|
+
if (abort.signal.aborted) {
|
|
203
|
+
opts.onClose()
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
if (Date.now() >= deadline) {
|
|
207
|
+
console.error(`${tag} follower-give-up sessionId=${sid} reason=pending-timeout attempts=${attempt}`)
|
|
208
|
+
opts.onError?.('follow-pending-timeout')
|
|
209
|
+
opts.onClose()
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
console.error(`${tag} follower-retry sessionId=${sid} attempt=${attempt} reason=pending`)
|
|
213
|
+
await new Promise((r) => setTimeout(r, followerRetryMs()))
|
|
214
|
+
if (abort.signal.aborted) {
|
|
215
|
+
opts.onClose()
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
break
|
|
221
|
+
}
|
|
222
|
+
if (!res.ok || !res.body) {
|
|
223
|
+
opts.onError?.(`follow-status-${res.status}`)
|
|
224
|
+
opts.onClose()
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
console.error(`${tag} follower-open sessionId=${sid}`)
|
|
228
|
+
const reader = res.body.getReader()
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The rest of the function (the `while (!abort.signal.aborted)` read loop, the `catch`, and
|
|
232
|
+
the `finally → opts.onClose()`) is unchanged. `onClose` still fires exactly once on every
|
|
233
|
+
exit path: each early `return` inside the loop calls `onClose()` directly and returns
|
|
234
|
+
before the read loop; the normal stream-end / abort / error paths fall through to the
|
|
235
|
+
existing `finally`.
|
|
236
|
+
|
|
237
|
+
- [ ] **Step 3: Run the new test to verify it passes**
|
|
238
|
+
|
|
239
|
+
Run: `cd maxy-code/platform && npx vitest run ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts -t "retries on 202"`
|
|
240
|
+
Expected: PASS — first fetch returns 202, follower logs `follower-retry`, second fetch
|
|
241
|
+
returns the 200 stream, `follower-open` logs, the `end_turn` record fans out `'greeting'`.
|
|
242
|
+
|
|
243
|
+
- [ ] **Step 4: Commit**
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
git add maxy-code/platform/ui/app/lib/channel-pty-bridge/follower.ts
|
|
247
|
+
git commit -m "fix(610): follower retries on 202 cold-start, never silently closes"
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
### Task 4: Give-up test — persistent 202 ends with onError + onClose, no fan-out
|
|
253
|
+
|
|
254
|
+
**Files:**
|
|
255
|
+
- Modify: `maxy-code/platform/ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts`
|
|
256
|
+
|
|
257
|
+
- [ ] **Step 1: Write the test**
|
|
258
|
+
|
|
259
|
+
Add inside the `describe` block:
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
it('gives up after the pending window: onError + onClose, no fan-out', async () => {
|
|
263
|
+
const stream = makeJsonlStream()
|
|
264
|
+
// Every follow fetch returns 202 — the JSONL never appears.
|
|
265
|
+
mgr = installManagerFetch({
|
|
266
|
+
followStream: stream,
|
|
267
|
+
followStatuses: { 'sess-aaaaaaaa': [202] },
|
|
268
|
+
})
|
|
269
|
+
process.env.CHANNEL_PTY_FOLLOWER_RETRY_MS = '2'
|
|
270
|
+
process.env.CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS = '20'
|
|
271
|
+
const entry = makeEntry()
|
|
272
|
+
const received: string[] = []
|
|
273
|
+
entry.subscribers.add((text) => {
|
|
274
|
+
received.push(text)
|
|
275
|
+
})
|
|
276
|
+
const onError = vi.fn()
|
|
277
|
+
const onClose = vi.fn()
|
|
278
|
+
startFollower({ entry, tag: '[test]', onError, onClose })
|
|
279
|
+
await waitFor(() => onClose.mock.calls.length > 0)
|
|
280
|
+
expect(onError).toHaveBeenCalledWith('follow-pending-timeout')
|
|
281
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
282
|
+
expect(received).toEqual([])
|
|
283
|
+
})
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
- [ ] **Step 2: Run it**
|
|
287
|
+
|
|
288
|
+
Run: `cd maxy-code/platform && npx vitest run ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts -t "gives up"`
|
|
289
|
+
Expected: PASS — repeated 202s exceed the 20ms window, follower logs `follower-give-up`,
|
|
290
|
+
calls `onError('follow-pending-timeout')` and `onClose()` once, no text delivered.
|
|
291
|
+
|
|
292
|
+
- [ ] **Step 3: Commit**
|
|
293
|
+
|
|
294
|
+
```bash
|
|
295
|
+
git add maxy-code/platform/ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts
|
|
296
|
+
git commit -m "test(610): follower gives up after pending window"
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
### Task 5: Full suite + typecheck + lint
|
|
302
|
+
|
|
303
|
+
**Files:** none (verification only)
|
|
304
|
+
|
|
305
|
+
- [ ] **Step 1: Run the whole channel-pty-bridge suite**
|
|
306
|
+
|
|
307
|
+
Run: `cd maxy-code/platform && npx vitest run ui/app/lib/channel-pty-bridge/__tests__/`
|
|
308
|
+
Expected: PASS — all follower tests (6 now), plus dispatch-once, reaper, subscribe,
|
|
309
|
+
write-chain, public-session-exit, live-memory-eviction, spawn-refused.
|
|
310
|
+
|
|
311
|
+
- [ ] **Step 2: Typecheck the changed files**
|
|
312
|
+
|
|
313
|
+
Run: `cd maxy-code/platform && npx tsc --noEmit -p ui/tsconfig.json 2>&1 | grep -E 'follower|helpers' || echo "no type errors in changed files"`
|
|
314
|
+
Expected: `no type errors in changed files`.
|
|
315
|
+
|
|
316
|
+
- [ ] **Step 3: Lint the changed files**
|
|
317
|
+
|
|
318
|
+
Run: `cd maxy-code/platform && npx eslint ui/app/lib/channel-pty-bridge/follower.ts ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts ui/app/lib/channel-pty-bridge/__tests__/helpers.ts`
|
|
319
|
+
Expected: no errors.
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
### Task 6: Documentation — webchat turn lifecycle + new env vars
|
|
324
|
+
|
|
325
|
+
**Files:**
|
|
326
|
+
- Modify or create: the webchat turn lifecycle reference under `maxy-code/platform/plugins/docs/references/` and the matching internal note under `maxy-code/.docs/` (locate the existing channel-pty-bridge / webchat doc first with a grep; edit it rather than creating a duplicate).
|
|
327
|
+
|
|
328
|
+
- [ ] **Step 1: Locate the existing doc**
|
|
329
|
+
|
|
330
|
+
Run: `cd maxy-code && grep -rln "channel-pty-bridge\|dispatchOnce\|log-follow-open\|WEBCHAT_TURN_TIMEOUT" .docs/ platform/plugins/docs/references/ 2>/dev/null`
|
|
331
|
+
Edit the file(s) that own the webchat turn lifecycle. If none exists, add a short
|
|
332
|
+
"Webchat turn lifecycle" section to the most relevant existing reference.
|
|
333
|
+
|
|
334
|
+
- [ ] **Step 2: Document the cold-start retry and env vars**
|
|
335
|
+
|
|
336
|
+
Add prose covering: the follower opens `GET /:sessionId/log?follow=1`; a cold spawn
|
|
337
|
+
returns `202 {pending:true}` until claude flushes its first JSONL line; the follower
|
|
338
|
+
retries every `CHANNEL_PTY_FOLLOWER_RETRY_MS` (default 1000) until a 200 stream arrives or
|
|
339
|
+
`CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS` (default 120000) elapses, then gives up with
|
|
340
|
+
`follower-give-up`. Note the log signature for diagnosis: `follower-connect` →
|
|
341
|
+
`follower-retry`* → `follower-open` → `outbound`, mirroring the manager's `log-follow-open`.
|
|
342
|
+
|
|
343
|
+
- [ ] **Step 3: Commit**
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
git add maxy-code/.docs maxy-code/platform/plugins/docs/references
|
|
347
|
+
git commit -m "docs(610): webchat follower cold-start retry + env vars"
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
## Self-Review
|
|
353
|
+
|
|
354
|
+
**Spec coverage:**
|
|
355
|
+
- Follower retries on 202 → Task 3. ✓
|
|
356
|
+
- Env-tunable bound (give-up window + poll interval) → Task 3 Step 1, defaults 120000 / 1000. ✓
|
|
357
|
+
- Repro test (202→200, fan-out) → Task 2. ✓
|
|
358
|
+
- Give-up test → Task 4. ✓
|
|
359
|
+
- Warm-session regression (existing 4 tests unchanged) → Task 1 Step 2, Task 5 Step 1. ✓
|
|
360
|
+
- Observability log points (follower-connect / follower-retry / follower-open / follower-give-up) → Task 3 Step 2. ✓
|
|
361
|
+
- Manager untouched → no task modifies http-server.ts. ✓
|
|
362
|
+
- Docs → Task 6. ✓
|
|
363
|
+
- No behaviour change for other channels → no channel-specific code added; covered by shared-follower note in spec. ✓
|
|
364
|
+
|
|
365
|
+
**Placeholder scan:** Task 6 requires a grep-locate because the doc's exact path is not yet
|
|
366
|
+
known; the content to write is fully specified, only the target file is discovered at
|
|
367
|
+
execution. Acceptable — it is not a content placeholder.
|
|
368
|
+
|
|
369
|
+
**Type consistency:** `followerPendingMaxMs()` / `followerRetryMs()` used consistently;
|
|
370
|
+
`followStatuses` option name matches between helper definition (Task 1) and test use
|
|
371
|
+
(Tasks 2, 4); `follow-pending-timeout` error string matches between Task 3 and Task 4
|
|
372
|
+
assertion.
|
package/payload/platform/docs/superpowers/specs/2026-06-02-task-610-follower-202-retry-design.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Task 610 — Webchat follower 202 cold-start retry — design
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-02
|
|
4
|
+
**Task:** `maxy-code/.tasks/610-webchat-follower-202-cold-start-race-never-detects-end-turn.md`
|
|
5
|
+
**Status:** Approved, pre-implementation
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
Every public webchat turn returns "Sorry, something went wrong" even when the agent
|
|
10
|
+
completes successfully. Proven live on the realagent-code Pi: session `2a2a6239`
|
|
11
|
+
completed its turn (assistant `text` greeting, `stop_reason:"end_turn"`) at T+52s, yet
|
|
12
|
+
`dispatchOnce` returned `reject reason=turn-timeout` at the 120s `WEBCHAT_TURN_TIMEOUT_MS`.
|
|
13
|
+
|
|
14
|
+
Root cause, confirmed in code: the JSONL follower's first fetch of
|
|
15
|
+
`GET /:sessionId/log?follow=1` hits the pre-first-JSONL window of a cold-spawned PTY.
|
|
16
|
+
The manager returns `202 {pending:true}` (`http-server.ts:1468-1471`) when the PTY is
|
|
17
|
+
live but no JSONL exists yet. The follower guard is `if (!res.ok || !res.body)`
|
|
18
|
+
(`follower.ts:46`). **`202` satisfies `res.ok`** (200–299), so the follower falls
|
|
19
|
+
through, reads the pending JSON body as one non-event line (`type` undefined → skipped),
|
|
20
|
+
reads stream `done`, `break`s, runs `finally → onClose()`, and **never retries**. No
|
|
21
|
+
`follower-error`, no `fanOut`, no `outbound` — silent death. Public sessions idle-reap,
|
|
22
|
+
so every greeting is the first turn of a fresh spawn → every turn hits the race → every
|
|
23
|
+
turn times out.
|
|
24
|
+
|
|
25
|
+
## Decision
|
|
26
|
+
|
|
27
|
+
Fix the follower, not the manager. The follower retries on `202` with a bounded poll
|
|
28
|
+
loop until it gets a `200` stream or the give-up window elapses. The manager's `202`
|
|
29
|
+
branch is left unchanged — `202 {pending:true}` is the correct signal; the follower's
|
|
30
|
+
handling of it was the defect. This matches the task's observability spec, which is
|
|
31
|
+
entirely follower-centric (`follower-connect` / `follower-retry` / `follower-open` /
|
|
32
|
+
`follower-give-up`), and keeps the change in one file with a unit-testable fetch seam.
|
|
33
|
+
|
|
34
|
+
## The change — `follower.ts`
|
|
35
|
+
|
|
36
|
+
Wrap the single `fetch` in a bounded retry loop. Per attempt:
|
|
37
|
+
|
|
38
|
+
- `fetch(managerLogFollowUrl)`, then log `follower-connect sessionId=… status=<code>`.
|
|
39
|
+
- **`status === 202`**: log `follower-retry sessionId=… attempt=N reason=pending`,
|
|
40
|
+
cancel the pending response body, check `abort.signal.aborted` (return cleanly via
|
|
41
|
+
`finally → onClose` if aborted), check elapsed against the give-up window. If exceeded:
|
|
42
|
+
log `follower-give-up sessionId=… reason=pending-timeout attempts=N`, call
|
|
43
|
+
`onError('follow-pending-timeout')` + `onClose()`, return. Otherwise `sleep(interval)`
|
|
44
|
+
and retry.
|
|
45
|
+
- **`200` + body**: log `follower-open sessionId=…`, break into the existing
|
|
46
|
+
read/parse/accumulate/fan-out loop, unchanged.
|
|
47
|
+
- **any other non-ok (404/410/5xx)**: unchanged — `onError('follow-status-<code>')` +
|
|
48
|
+
`onClose()`. Session-gone respawn is handled by `writeInput`'s 410/404 path in
|
|
49
|
+
`bridge.ts`, not the follower.
|
|
50
|
+
|
|
51
|
+
Abort during a retry sleep ends the loop with no `onError` (just `onClose` via the outer
|
|
52
|
+
`finally`), matching today's abort-during-stream behaviour. The retry loop sits inside the
|
|
53
|
+
existing `try/finally`, so `onClose` fires exactly once on every exit path.
|
|
54
|
+
|
|
55
|
+
## Config
|
|
56
|
+
|
|
57
|
+
Two env vars, mirroring `CHANNEL_PTY_IDLE_MS` in `bridge.ts`:
|
|
58
|
+
|
|
59
|
+
- `CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS` — give-up window, default `120_000`. Covers
|
|
60
|
+
claude cold-boot and matches the webchat 120s turn window. If the follower gives up
|
|
61
|
+
before the JSONL appears, the turn times out anyway, so the bound need not exceed the
|
|
62
|
+
turn window.
|
|
63
|
+
- `CHANNEL_PTY_FOLLOWER_RETRY_MS` — poll interval between `202` retries, default `1_000`.
|
|
64
|
+
|
|
65
|
+
Both read inside `follower.ts` at loop entry.
|
|
66
|
+
|
|
67
|
+
## No behaviour change elsewhere
|
|
68
|
+
|
|
69
|
+
- **Warm sessions**: first fetch returns `200`, logs `follower-connect status=200` +
|
|
70
|
+
`follower-open`, streams as today. The four existing follower tests cover this path
|
|
71
|
+
unchanged.
|
|
72
|
+
- **whatsapp / email**: same shared follower. They only see new behaviour if they ever
|
|
73
|
+
cold-start into the `202` window — the same bug, fixed once. No channel-specific code.
|
|
74
|
+
- **Manager**: untouched. `202`/`200`/`404`/`410` contract is unchanged.
|
|
75
|
+
|
|
76
|
+
## Tests (ephemeral)
|
|
77
|
+
|
|
78
|
+
In `__tests__/follower.test.ts`, with a small `helpers.ts` extension to script a follow
|
|
79
|
+
response sequence (`202` then `200`-stream) per sessionId:
|
|
80
|
+
|
|
81
|
+
1. **Repro**: first follow fetch returns `202 {pending:true}`, second returns a `200`
|
|
82
|
+
stream carrying an `end_turn`+text record → subscriber receives the text. Fails today
|
|
83
|
+
(silent close, no fan-out).
|
|
84
|
+
2. **Give-up**: follow keeps returning `202` past `CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS`
|
|
85
|
+
(set small in the test) → `onError` + `onClose` fire, no fan-out.
|
|
86
|
+
3. **Regression**: the four existing follower tests pass unchanged (warm 200 path,
|
|
87
|
+
user-event reset, malformed-line skip, multi-subscriber fan-out, abort→onClose).
|
|
88
|
+
|
|
89
|
+
Tests are run to prove the fix during the sprint and discarded unless they belong in the
|
|
90
|
+
existing committed suite — this suite is already tracked, so the new cases stay.
|
|
91
|
+
|
|
92
|
+
## Observability (per task spec)
|
|
93
|
+
|
|
94
|
+
- Success signal: `follower-open` + `outbound bytes>0` per webchat turn; the manager's
|
|
95
|
+
`log-follow-open` for that sessionId now appears (absent today for every timed-out turn).
|
|
96
|
+
- Failure signal: `reject reason=turn-timeout` with no preceding `follower-open` =
|
|
97
|
+
follower never connected. With the fix, a persistent cold session emits
|
|
98
|
+
`follower-give-up` instead of dying silently.
|
|
99
|
+
|
|
100
|
+
## Out of scope (separate tasks)
|
|
101
|
+
|
|
102
|
+
- Task 609 — fail-open public `--allowed-tools` / `memory-search` strip. Independent.
|
|
103
|
+
- WhatsApp/email turn-timeout tuning — no change beyond incidental shared-follower coverage.
|
|
104
|
+
- Session reuse / idle-reap tuning (why each turn cold-spawns) — separate concern.
|
|
105
|
+
- Manager-side connection hold — explicitly rejected in favour of the follower retry.
|
|
106
|
+
|
|
107
|
+
## Touches
|
|
108
|
+
|
|
109
|
+
- `platform/ui/app/lib/channel-pty-bridge/follower.ts`
|
|
110
|
+
- `platform/ui/app/lib/channel-pty-bridge/__tests__/follower.test.ts`
|
|
111
|
+
- `platform/ui/app/lib/channel-pty-bridge/__tests__/helpers.ts`
|
|
112
|
+
- `.docs/` + `platform/plugins/docs/references/` (webchat turn lifecycle + new env vars)
|
|
113
|
+
- `.tasks/LANES.md`, archive task file
|
|
114
|
+
|
|
115
|
+
Installer publish required — `platform/ui/**` ships in the bundled `create-realagent-code` /
|
|
116
|
+
`create-maxy-code` payload.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: platform-architecture
|
|
3
3
|
description: Use when grounding any documented-surface claim about what Maxy ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
|
|
4
|
-
content-hash: sha256:
|
|
4
|
+
content-hash: sha256:d2eace535144fcd072136be31999e193f6903ff674e098517be0b8feb570f4cf
|
|
5
5
|
brand: maxy-code
|
|
6
6
|
product-name: Maxy
|
|
7
7
|
---
|
|
@@ -3147,6 +3147,8 @@ This gate was Task 173. The `brand-excluded` branch closes the recurring crash-r
|
|
|
3147
3147
|
|
|
3148
3148
|
**Per-spawn signals (server.log).** Every spawn emits `pty-spawn-mcp-config servers=<N> tools=<M> bytes=<B> path=<…>` once, plus one `pty-spawn-agents-dir role=<admin|public> path=<…>` per added directory. Specialist spawns additionally emit `pty-spawn-allowlist specialist=<name> count=<N> stripped=<S> sourced-from=agent-frontmatter` where `stripped` is the count of brand-excluded tool names removed before argv emission. The diagnostic one-liner is `grep -E 'pty-spawn-mcp-config|pty-spawn-agents-dir|pty-spawn-allowlist|mcp-config-allowlist-coverage|specialist-tool-strip|boot-failed reason=' ~/.<brand>/logs/server.log | tail -50`.
|
|
3149
3149
|
|
|
3150
|
+
**Channel follower cold-start retry (Task 610).** Each channel PTY session (webchat, whatsapp, email) has one JSONL follower ([`platform/ui/app/lib/channel-pty-bridge/follower.ts`](../../../ui/app/lib/channel-pty-bridge/follower.ts)) reading `GET /<sessionId>/log?follow=1` and fanning each assistant `end_turn` out to the awaiting `dispatchOnce`. A freshly-spawned PTY has no JSONL on disk until claude flushes its first line; during that window the manager answers `202 {pending:true}`. The follower retries every `CHANNEL_PTY_FOLLOWER_RETRY_MS` (default 1000) until a 200 stream opens or `CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS` elapses. The follower is shared across channels, so that window defaults to the longest channel turn window (whatsapp's `WHATSAPP_PTY_TURN_TIMEOUT_MS`, 300000 — longer than webchat's 120000) so it never abandons a turn the caller is still awaiting. Because public sessions idle-reap, every webchat greeting is the first turn of a fresh spawn and crosses this window — before the retry, a 202 (which satisfies `res.ok`) was consumed as a single non-event line, the stream ended, and the follower died silently, timing out every public turn. The lifecycle is greppable as `follower-connect status=<code>` → `follower-retry attempt=N reason=pending` → `follower-open` → `outbound bytes=N`; `follower-give-up reason=pending-timeout` marks the JSONL never appearing. A `reject reason=turn-timeout` with no preceding `follower-open` (and no manager `log-follow-open`) for that sessionId is the Task 610 signature. See `.docs/gated-public-agents.md` "Webchat turn lifecycle" for the full tag list.
|
|
3151
|
+
|
|
3150
3152
|
**Brand-process start counter (Task 173).** `platform/ui/server-init.cjs` increments a persistent counter at `/tmp/server-init-<accountId>-restart.count` on every fresh start and emits `[server-init] start count=<N> account=<accountId> counter-path=<…>` to `server.log`. /tmp clears on reboot, so a clean reboot starts the count fresh; any value `>1` between operator-observed reboots means the brand process (driven by its `Requires=<brand>-claude-session-manager.service` clause) is restarting. The diagnostic one-liner is `grep '\[server-init\] start' ~/.<brand>/logs/server.log | tail -5` — the trailing `count=` value is the loop depth without counting SIGTERMs.
|
|
3151
3153
|
|
|
3152
3154
|
**Programmatic spawn entry point.** Every admin PTY spawn that needs a first user prompt — UI click, turn-recorder hook, future automation — routes through the single wrapper at [`platform/ui/server/routes/admin/claude-sessions.ts`](../../../ui/server/routes/admin/claude-sessions.ts). The wrapper owns the per-spawn enrichment (owner profile, dormant/active plugins, specialist domains, tunnel URL) and the `senderId` resolution; it forwards a single `POST /spawn` to the session manager on `127.0.0.1`, with `initialMessage` inlined on that body. The manager appends `initialMessage` as the trailing positional argv to `claude`, so the CLI processes it as the session's first user turn at PTY startup — no separate `POST /<sessionId>/input` call, no bracketed-paste. (Task 153.) See `admin-session.md` "Spawn-with-initialMessage wrapper" for the body schema and caller list.
|
|
@@ -463,6 +463,8 @@ This gate was Task 173. The `brand-excluded` branch closes the recurring crash-r
|
|
|
463
463
|
|
|
464
464
|
**Per-spawn signals (server.log).** Every spawn emits `pty-spawn-mcp-config servers=<N> tools=<M> bytes=<B> path=<…>` once, plus one `pty-spawn-agents-dir role=<admin|public> path=<…>` per added directory. Specialist spawns additionally emit `pty-spawn-allowlist specialist=<name> count=<N> stripped=<S> sourced-from=agent-frontmatter` where `stripped` is the count of brand-excluded tool names removed before argv emission. The diagnostic one-liner is `grep -E 'pty-spawn-mcp-config|pty-spawn-agents-dir|pty-spawn-allowlist|mcp-config-allowlist-coverage|specialist-tool-strip|boot-failed reason=' ~/.<brand>/logs/server.log | tail -50`.
|
|
465
465
|
|
|
466
|
+
**Channel follower cold-start retry (Task 610).** Each channel PTY session (webchat, whatsapp, email) has one JSONL follower ([`platform/ui/app/lib/channel-pty-bridge/follower.ts`](../../../ui/app/lib/channel-pty-bridge/follower.ts)) reading `GET /<sessionId>/log?follow=1` and fanning each assistant `end_turn` out to the awaiting `dispatchOnce`. A freshly-spawned PTY has no JSONL on disk until claude flushes its first line; during that window the manager answers `202 {pending:true}`. The follower retries every `CHANNEL_PTY_FOLLOWER_RETRY_MS` (default 1000) until a 200 stream opens or `CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS` elapses. The follower is shared across channels, so that window defaults to the longest channel turn window (whatsapp's `WHATSAPP_PTY_TURN_TIMEOUT_MS`, 300000 — longer than webchat's 120000) so it never abandons a turn the caller is still awaiting. Because public sessions idle-reap, every webchat greeting is the first turn of a fresh spawn and crosses this window — before the retry, a 202 (which satisfies `res.ok`) was consumed as a single non-event line, the stream ended, and the follower died silently, timing out every public turn. The lifecycle is greppable as `follower-connect status=<code>` → `follower-retry attempt=N reason=pending` → `follower-open` → `outbound bytes=N`; `follower-give-up reason=pending-timeout` marks the JSONL never appearing. A `reject reason=turn-timeout` with no preceding `follower-open` (and no manager `log-follow-open`) for that sessionId is the Task 610 signature. See `.docs/gated-public-agents.md` "Webchat turn lifecycle" for the full tag list.
|
|
467
|
+
|
|
466
468
|
**Brand-process start counter (Task 173).** `platform/ui/server-init.cjs` increments a persistent counter at `/tmp/server-init-<accountId>-restart.count` on every fresh start and emits `[server-init] start count=<N> account=<accountId> counter-path=<…>` to `server.log`. /tmp clears on reboot, so a clean reboot starts the count fresh; any value `>1` between operator-observed reboots means the brand process (driven by its `Requires=<brand>-claude-session-manager.service` clause) is restarting. The diagnostic one-liner is `grep '\[server-init\] start' ~/.<brand>/logs/server.log | tail -5` — the trailing `count=` value is the loop depth without counting SIGTERMs.
|
|
467
469
|
|
|
468
470
|
**Programmatic spawn entry point.** Every admin PTY spawn that needs a first user prompt — UI click, turn-recorder hook, future automation — routes through the single wrapper at [`platform/ui/server/routes/admin/claude-sessions.ts`](../../../ui/server/routes/admin/claude-sessions.ts). The wrapper owns the per-spawn enrichment (owner profile, dormant/active plugins, specialist domains, tunnel URL) and the `senderId` resolution; it forwards a single `POST /spawn` to the session manager on `127.0.0.1`, with `initialMessage` inlined on that body. The manager appends `initialMessage` as the trailing positional argv to `claude`, so the CLI processes it as the session's first user turn at PTY startup — no separate `POST /<sessionId>/input` call, no bracketed-paste. (Task 153.) See `admin-session.md` "Spawn-with-initialMessage wrapper" for the body schema and caller list.
|
package/payload/server/server.js
CHANGED
|
@@ -4371,19 +4371,49 @@ async function firePublicSessionEndReview(input) {
|
|
|
4371
4371
|
}
|
|
4372
4372
|
|
|
4373
4373
|
// app/lib/channel-pty-bridge/follower.ts
|
|
4374
|
+
function followerPendingMaxMs() {
|
|
4375
|
+
return Number(process.env.CHANNEL_PTY_FOLLOWER_PENDING_MAX_MS ?? String(3e5));
|
|
4376
|
+
}
|
|
4377
|
+
function followerRetryMs() {
|
|
4378
|
+
return Number(process.env.CHANNEL_PTY_FOLLOWER_RETRY_MS ?? String(1e3));
|
|
4379
|
+
}
|
|
4374
4380
|
function startFollower(opts) {
|
|
4375
4381
|
const abort = new AbortController();
|
|
4376
4382
|
const { entry, tag } = opts;
|
|
4377
4383
|
void (async () => {
|
|
4378
4384
|
try {
|
|
4379
|
-
const
|
|
4380
|
-
|
|
4381
|
-
|
|
4385
|
+
const sid = entry.sessionId.slice(0, 8);
|
|
4386
|
+
const deadline = Date.now() + followerPendingMaxMs();
|
|
4387
|
+
const retryMs = followerRetryMs();
|
|
4388
|
+
let res;
|
|
4389
|
+
let attempt = 0;
|
|
4390
|
+
for (; ; ) {
|
|
4391
|
+
res = await fetch(managerLogFollowUrl(entry.sessionId), {
|
|
4392
|
+
signal: abort.signal
|
|
4393
|
+
});
|
|
4394
|
+
console.error(`${tag} follower-connect sessionId=${sid} status=${res.status}`);
|
|
4395
|
+
if (res.status === 202) {
|
|
4396
|
+
attempt += 1;
|
|
4397
|
+
await res.body?.cancel().catch(() => {
|
|
4398
|
+
});
|
|
4399
|
+
if (abort.signal.aborted) return;
|
|
4400
|
+
if (Date.now() >= deadline) {
|
|
4401
|
+
console.error(`${tag} follower-give-up sessionId=${sid} reason=pending-timeout attempts=${attempt}`);
|
|
4402
|
+
opts.onError?.("follow-pending-timeout");
|
|
4403
|
+
return;
|
|
4404
|
+
}
|
|
4405
|
+
console.error(`${tag} follower-retry sessionId=${sid} attempt=${attempt} reason=pending`);
|
|
4406
|
+
await new Promise((r) => setTimeout(r, retryMs));
|
|
4407
|
+
if (abort.signal.aborted) return;
|
|
4408
|
+
continue;
|
|
4409
|
+
}
|
|
4410
|
+
break;
|
|
4411
|
+
}
|
|
4382
4412
|
if (!res.ok || !res.body) {
|
|
4383
4413
|
opts.onError?.(`follow-status-${res.status}`);
|
|
4384
|
-
opts.onClose();
|
|
4385
4414
|
return;
|
|
4386
4415
|
}
|
|
4416
|
+
console.error(`${tag} follower-open sessionId=${sid}`);
|
|
4387
4417
|
const reader = res.body.getReader();
|
|
4388
4418
|
const decoder = new TextDecoder("utf8");
|
|
4389
4419
|
let buffered = "";
|