Skip to content

Commit be28ccc

Browse files
committed
chore(patches): 密封批次 426-452 修复与全部审计记录
批次 436-440(Round-5 修复后双向重审一致 PASS): - 0436 收缩为 docs/workflow.md 原文两 hunk:移除无 source 基础的 anomalyco#434 第二分类层 (message-v2 cause 循环死代码、retry 顶层形状恒假检查、llm-server gate 零消费者、 prompt.ts 跨批注解),SSE deadline 语义由既有 transportError 路径完整承载 - 0440 版本 bump 1.17.30→1.17.31-smark + //440-release-note JSON 键 + CONTEXT.md :231 版本锚(v1.15.10→v1.15.11-smark,anomalyco#396 先例字面同构),record 重写为实证一致版本 批次 441-445(Round-5 修复后双向重审一致 PASS): - 0441/0443 从 git HEAD 恢复被剥离内容并 rebase(index.ts/sync.tsx 锚点适配) - 0444 单调合并合同移植到真实 owner server-session.ts:import 扩展、replaceParts page 路径合并、message.part.progress fallthrough + part.updated 写入前 mergePartSnapshot(事件路径防回退),新增 page/event 双 stale 测试(70 pass) 批次 446-450(双向重审一致 PASS): - 0446 voice hunk 重建(no-newline 标记);0447 五处 hunk 级重建(游离行删除、 dir/file 声明补齐、ctx 名称修正);0450 版本值适配 OUR 链(1.17.28→1.17.31) + README/package.json hunk 按 0449 态精确重建 批次 426-430(0429 immediate 修订后 seal 失效重审一致 PASS): - 0429 恢复 source 精确事务语义 { behavior: "immediate" }(BEGIN IMMEDIATE 消除 read-validate-write 竞态),0433/0435 链式 rebase records:全部 Round-4/Round-5 修订段更新;补录 0346-0355/0391/0392/0421-0425 缺失的 canonical record 文件(17 个,此前未入库) 最终验证:materialize 452/452(exact baseline provenance)、typecheck 30/30 workspace、行为测试全套实跑通过(message-v2 50/lsp 15/server-session 70 等); 全部批次正向+逆向双独立审计一致 PASS,无未关闭 B/G。
1 parent 1fd8e6f commit be28ccc

370 files changed

Lines changed: 29402 additions & 66262 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
0 Bytes
Binary file not shown.

‎.temp/patches/current/0173-59aeb3d239f3.patch‎

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,186 @@ diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/t
271271
it.instance(
272272
"cancel propagates from running task tool to child session",
273273
() =>
274+
@@ -1682,6 +1682,9 @@
275+
const childID = SessionID.make(sessionID)
276+
expect((yield* status.get(childID)).type).toBe("busy")
277+
278+
+ // #173:并发双 cancel——fork 的 cancel 与串行 cancel 竞争同一 per-session 锁,验证排队不丢弃
279+
+ const cancel = yield* prompt.cancel(chat.id).pipe(Effect.forkChild)
280+
yield* prompt.cancel(chat.id)
281+
+ yield* Fiber.await(cancel)
282+
const exit = yield* Fiber.await(fiber)
283+
expect(Exit.isSuccess(exit)).toBe(true)
284+
diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts
285+
index fd4cb74..0510167 100644
286+
--- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts
287+
+++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts
288+
@@ -5,21 +5,16 @@ import { EventV2 } from "@opencode-ai/core/event"
289+
import { Installation } from "@/installation"
290+
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
291+
import { InstallationVersion } from "@opencode-ai/core/installation/version"
292+
-import { Effect, Queue, Schema } from "effect"
293+
-import * as Stream from "effect/Stream"
294+
+import { Effect, Schema, Stream } from "effect"
295+
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
296+
import { HttpApiBuilder } from "effect/unstable/httpapi"
297+
-import * as Sse from "effect/unstable/encoding/Sse"
298+
import { RootHttpApi } from "../api"
299+
+import { PassThrough, Readable } from "node:stream"
300+
import { GlobalUpgradeInput } from "../groups/global"
301+
302+
-function eventData(data: unknown): Sse.Event {
303+
- return {
304+
- _tag: "Event",
305+
- event: "message",
306+
- id: undefined,
307+
- data: JSON.stringify(data),
308+
- }
309+
+function eventData(data: unknown) {
310+
+ // SSE 帧格式:data: 前缀 + 空行结尾,替代 v3 时代的 Sse.encode 通道
311+
+ return `data: ${JSON.stringify(data)}\n\n`
312+
}
313+
314+
function parseBody(body: string) {
315+
@@ -38,48 +33,84 @@ export function onSseClientCountChange(cb: (n: number) => void) {
316+
onSseCountChange = cb
317+
}
318+
319+
-function eventResponse() {
320+
- return Effect.gen(function* () {
321+
- // SSE 客户端连接计数 +1,通知守护进程空闲计时器
322+
- sseClientCount++
323+
+type AbortSource = {
324+
+ once: (event: "aborted", listener: () => void) => unknown
325+
+ off: (event: "aborted", listener: () => void) => unknown
326+
+}
327+
+
328+
+function abortSource(source: object): AbortSource | undefined {
329+
+ // 兼容 Web Request 与 Node IncomingMessage 两种 source 形态,双路保证 abort 可达
330+
+ const candidate = source as { once?: unknown; off?: unknown }
331+
+ if (typeof candidate.once !== "function" || typeof candidate.off !== "function") return
332+
+ return candidate as AbortSource
333+
+}
334+
+
335+
+function onRequestAbort(request: HttpServerRequest.HttpServerRequest, close: () => void) {
336+
+ if (request.source instanceof Request) {
337+
+ // Web Request 路径:signal abort 即客户端断连,once 防止 close 重复进入
338+
+ request.source.signal.addEventListener("abort", close, { once: true })
339+
+ return () => request.source instanceof Request && request.source.signal.removeEventListener("abort", close)
340+
+ }
341+
+
342+
+ const source = abortSource(request.source)
343+
+ if (!source) return () => {}
344+
+ source.once("aborted", close)
345+
+ return () => source.off("aborted", close)
346+
+}
347+
+function eventResponse(request: HttpServerRequest.HttpServerRequest) {
348+
+ // SSE 客户端连接计数 +1,通知守护进程空闲计时器
349+
+ sseClientCount++
350+
+ onSseCountChange?.(sseClientCount)
351+
+ const stream = new PassThrough()
352+
+ const write = (event: GlobalBusEvent) => {
353+
+ // abort 竞态下 close 可能已跑但 GlobalBus 仍派发,此时静默丢弃写入
354+
+ if (stream.destroyed) return
355+
+ stream.write(eventData(event))
356+
+ }
357+
+ const handler = (event: GlobalBusEvent) => write(event)
358+
+ const heartbeat = setInterval(
359+
+ () => write({ payload: { id: EventV2.ID.create(), type: "server.heartbeat", properties: {} } }),
360+
+ 10_000,
361+
+ )
362+
+ let closed = false
363+
+ let unsubscribeRequestAbort = () => {}
364+
+ // #173:close 确定性清理——客户端中断不保证触发 Stream finalizer
365+
+ const close = () => {
366+
+ if (closed) return
367+
+ closed = true
368+
+ clearInterval(heartbeat)
369+
+ // 清理责任全在此处:停止心跳、退订总线、解除 abort 监听、收流,全部确定性执行
370+
+ GlobalBus.off("event", handler)
371+
+ unsubscribeRequestAbort()
372+
+ if (!stream.destroyed) stream.end()
373+
+ // 下限保护:close 幂等触发(abort 与 stream close 双路)时不得把计数扣成负数
374+
+ sseClientCount = Math.max(0, sseClientCount - 1)
375+
onSseCountChange?.(sseClientCount)
376+
- yield* Effect.logInfo("global event connected")
377+
- const events = Stream.callback<GlobalBusEvent>((queue) => {
378+
- const handler = (event: GlobalBusEvent) => Queue.offerUnsafe(queue, event)
379+
- return Effect.acquireRelease(
380+
- Effect.sync(() => GlobalBus.on("event", handler)),
381+
- () => Effect.sync(() => GlobalBus.off("event", handler)),
382+
- )
383+
- })
384+
- const heartbeat = Stream.tick("10 seconds").pipe(
385+
- Stream.drop(1),
386+
- Stream.map(() => ({ payload: { id: EventV2.ID.create(), type: "server.heartbeat", properties: {} } })),
387+
- )
388+
-
389+
- return HttpServerResponse.stream(
390+
- Stream.make({ payload: { id: EventV2.ID.create(), type: "server.connected", properties: {} } }).pipe(
391+
- Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
392+
- Stream.map(eventData),
393+
- Stream.pipeThroughChannel(Sse.encode()),
394+
- Stream.encodeText,
395+
- Stream.ensuring(Effect.logInfo("global event disconnected")),
396+
- // SSE 客户端断开时计数 -1,通知守护进程可能需要启动空闲计时器
397+
- Stream.ensuring(
398+
- Effect.sync(() => {
399+
- sseClientCount--
400+
- onSseCountChange?.(sseClientCount)
401+
- }),
402+
- ),
403+
- ),
404+
- {
405+
- contentType: "text/event-stream",
406+
- headers: {
407+
- "Cache-Control": "no-cache, no-transform",
408+
- "X-Accel-Buffering": "no",
409+
- "X-Content-Type-Options": "nosniff",
410+
- },
411+
- },
412+
- )
413+
+ }
414+
+
415+
+ // unref 让心跳计时器不阻止守护进程自然退出(idle 退出依赖进程无强引用)
416+
+ heartbeat.unref?.()
417+
+ // 与 source 同位单次订阅;先于 connected 写入,避免订阅空窗期丢失首帧事件
418+
+ GlobalBus.on("event", handler)
419+
+ unsubscribeRequestAbort = onRequestAbort(request, close)
420+
+ // 断连侧兜底:close 幂等(closed 标志)保证 abort 与流关闭双触发只清理一次
421+
+ stream.once("close", close)
422+
+ stream.once("error", close)
423+
+ write({ payload: { id: EventV2.ID.create(), type: "server.connected", properties: {} } })
424+
+
425+
+ // 目标 v4 合同(httpapi AGENTS)要求 SSE 返回 HttpServerResponse.stream;PassThrough 经 toWeb 桥接,
426+
+ // 终止仍由 close() 确定性拥有(abort/断连双路触发),不回退到不可靠的 Stream finalizer
427+
+ const body = Stream.fromReadableStream({
428+
+ evaluate: () => Readable.toWeb(stream) as unknown as ReadableStream<Uint8Array>,
429+
+ onError: (cause) => new Error(`sse body error: ${String(cause)}`),
430+
+ })
431+
+ return HttpServerResponse.stream(body, {
432+
+ contentType: "text/event-stream",
433+
+ headers: {
434+
+ "Cache-Control": "no-cache, no-transform",
435+
+ "X-Accel-Buffering": "no",
436+
+ "X-Content-Type-Options": "nosniff",
437+
+ },
438+
})
439+
}
440+
441+
@@ -94,7 +125,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
442+
})
443+
444+
const event = Effect.fn("GlobalHttpApi.event")(function* () {
445+
- return yield* eventResponse()
446+
+ return eventResponse(yield* HttpServerRequest.HttpServerRequest)
447+
})
448+
449+
const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {
450+
@@ -171,4 +202,4 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
451+
.handle("dispose", dispose)
452+
.handleRaw("upgrade", upgradeRaw)
453+
}),
454+
-)
455+
+)
456+
\ No newline at end of file

0 commit comments

Comments
 (0)