From 1abf0f55fe51678986343f62b2077976ecec5cdc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 17:39:09 +0800 Subject: [PATCH 01/10] docs(forge): clarify discovery limits and provider contracts --- docs/subsystems/forge.md | 54 +++++++++++++++++++ docs/subsystems/forge.zh.md | 54 +++++++++++++++++++ .../forge-session/README.i18n.yaml | 8 +-- packages/experimental/forge-session/README.md | 4 +- .../experimental/forge-session/README.zh.md | 4 +- .../forge-session/src/commands.ts | 5 +- packages/experimental/forge/README.i18n.yaml | 4 +- packages/experimental/forge/README.md | 4 +- packages/experimental/forge/README.zh.md | 4 +- scripts/type-equiv.manifest.json | 40 ++++++++++++++ 10 files changed, 169 insertions(+), 12 deletions(-) diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md index 8dc217841d..841577d257 100644 --- a/docs/subsystems/forge.md +++ b/docs/subsystems/forge.md @@ -32,6 +32,11 @@ interface ChangeRequestRef { } ``` +```ts type-equiv +/** Canonical identity of a change request across providers; built by `changeRequestKey`. */ +type ChangeRequestKey = Branded<'ChangeRequestKey'> +``` + ```ts type-equiv /** * Host-specific access registered with `ctx.forge`. One provider serves one @@ -60,6 +65,55 @@ interface ForgeProvider { `ctx.forge.watch` shares one polling loop among the watchers of a change request and delivers every result as an update. Each forge has at most one fetch in flight. The [registry README](../../packages/experimental/forge/README.md#use-this-package) owns the poll cadence, backoff, and timeout rules. +```ts type-equiv +/** Lifecycle state of a change request. */ +type ChangeRequestState = 'open' | 'closed' | 'merged' +``` + +```ts type-equiv +/** Normalized outcome of one status check. */ +type CheckState = 'pending' | 'success' | 'failure' | 'neutral' | 'skipped' | 'cancelled' +``` + +```ts type-equiv +/** One status check reported for the head commit of a change request. */ +interface ChangeRequestCheck { + /** Check name as the host displays it. */ + readonly name: string + /** Normalized check outcome. */ + readonly state: CheckState + /** Web page for the check, or null when the host reports none. */ + readonly url: string | null + /** Workflow that produced the check, or null when the host reports none. */ + readonly workflow: string | null +} +``` + +```ts type-equiv +/** Combined outcome of all checks on the head commit. */ +type ChecksRollup = 'none' | 'pending' | 'success' | 'failure' +``` + +```ts type-equiv +/** Whether the change request can merge into its base without conflicts. */ +type Mergeability = 'mergeable' | 'conflicting' | 'unknown' +``` + +```ts type-equiv +/** Aggregate review outcome required by the host's merge rules. */ +type ReviewDecision = 'approved' | 'changes-requested' | 'review-required' | 'none' +``` + +```ts type-equiv +/** Why a provider could not read a change request. */ +interface ForgeFailure { + /** `not-found`: the host does not expose it; `unauthenticated`: credentials are missing or rejected; `unavailable`: any other failure. */ + readonly code: 'not-found' | 'unauthenticated' | 'unavailable' + /** Human-readable cause. */ + readonly message: string +} +``` + ```ts type-equiv /** Status of one change request at the time its provider read it. */ interface ChangeRequestSnapshot { diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md index 28b7d1d782..97555d1e05 100644 --- a/docs/subsystems/forge.zh.md +++ b/docs/subsystems/forge.zh.md @@ -32,6 +32,11 @@ interface ChangeRequestRef { } ``` +```ts type-equiv +/** Canonical identity of a change request across providers; built by `changeRequestKey`. */ +type ChangeRequestKey = Branded<'ChangeRequestKey'> +``` + ```ts type-equiv /** * Host-specific access registered with `ctx.forge`. One provider serves one @@ -60,6 +65,55 @@ interface ForgeProvider { `ctx.forge.watch` 让同一变更请求的所有监听方共享一个轮询循环,并把每次结果作为更新送达。每个 forge 同时最多只有一次读取在进行。轮询节奏、退避与超时规则见[注册表 README](../../packages/experimental/forge/README.zh.md#use-this-package)。 +```ts type-equiv +/** Lifecycle state of a change request. */ +type ChangeRequestState = 'open' | 'closed' | 'merged' +``` + +```ts type-equiv +/** Normalized outcome of one status check. */ +type CheckState = 'pending' | 'success' | 'failure' | 'neutral' | 'skipped' | 'cancelled' +``` + +```ts type-equiv +/** One status check reported for the head commit of a change request. */ +interface ChangeRequestCheck { + /** Check name as the host displays it. */ + readonly name: string + /** Normalized check outcome. */ + readonly state: CheckState + /** Web page for the check, or null when the host reports none. */ + readonly url: string | null + /** Workflow that produced the check, or null when the host reports none. */ + readonly workflow: string | null +} +``` + +```ts type-equiv +/** Combined outcome of all checks on the head commit. */ +type ChecksRollup = 'none' | 'pending' | 'success' | 'failure' +``` + +```ts type-equiv +/** Whether the change request can merge into its base without conflicts. */ +type Mergeability = 'mergeable' | 'conflicting' | 'unknown' +``` + +```ts type-equiv +/** Aggregate review outcome required by the host's merge rules. */ +type ReviewDecision = 'approved' | 'changes-requested' | 'review-required' | 'none' +``` + +```ts type-equiv +/** Why a provider could not read a change request. */ +interface ForgeFailure { + /** `not-found`: the host does not expose it; `unauthenticated`: credentials are missing or rejected; `unavailable`: any other failure. */ + readonly code: 'not-found' | 'unauthenticated' | 'unavailable' + /** Human-readable cause. */ + readonly message: string +} +``` + ```ts type-equiv /** Status of one change request at the time its provider read it. */ interface ChangeRequestSnapshot { diff --git a/packages/experimental/forge-session/README.i18n.yaml b/packages/experimental/forge-session/README.i18n.yaml index 382eab490b..b0b2696097 100644 --- a/packages/experimental/forge-session/README.i18n.yaml +++ b/packages/experimental/forge-session/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge-session/use-this-package: - en: 23898b4f5a21b3b5 - zh: 6d1251b9b862fad8 + en: 0b75d06236040dd9 + zh: ff30c83707003bca /deepseek-ai-dsh-experimental-forge-session/understand-the-implementation: en: ccc7ad69f5837528 zh: 369c08bf4f7e6e4d @@ -30,8 +30,8 @@ en: 1bdaeb7d102c23d8 zh: 37a3f0bd58f68363 /deepseek-ai-dsh-experimental-forge-session/known-limitations-and-deferred-work: - en: 3a8277e1f874da4a - zh: 987b0e6030583112 + en: 08bc0a8f37be268f + zh: 23e748f82384238b /deepseek-ai-dsh-experimental-forge-session/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/forge-session/README.md b/packages/experimental/forge-session/README.md index 61a8d5f870..bb21b3d288 100644 --- a/packages/experimental/forge-session/README.md +++ b/packages/experimental/forge-session/README.md @@ -36,7 +36,7 @@ Mount the service with the forge registry, a provider, and the Session projectio `ctx.forgeSession.changeRequests(session)` returns the visible bindings in first-recorded order. Each entry carries the URL, its `origin` (`created`, `discovered`, or `tracked`), the provider reference or `null` when no provider serves the URL, and the latest polling result or `null`. `watch(session, listener)` is an effect on the calling fiber: while a Session has a watcher, every visible binding that a provider serves is polled, and listeners receive the complete list after each later binding change or polling result. `track(session, url)` binds a URL under the provider's canonical form, or restores a dismissed binding, and throws when no provider recognizes the URL. `dismiss(session, url)` hides a visible binding; other URLs leave the log unchanged. -A `bash` or `pwsh` call binds evidence only from its first result, and only when the tool execution itself succeeded; the command's exit status does not matter. A command containing `gh pr create` or `gh pr new` binds every `/pull/` URL in its text output. A command containing `git push`, optionally with leading `-C`, `-c`, or long Git options, records each branch listed under a `To ` line as created, updated, forced, or already up to date; rejected, deleted, tag, and non-branch refs are skipped. For each pushed branch whose remote a provider serves, the service runs `ctx.forge.findByHead` within `discoveryTimeoutMs` and appends a `discover` record for every returned change request that is not bound, including dismissed ones. Every push repeats the lookup; failures are logged as warnings. URLs that differ only in scheme, letter case, query, fragment, or trailing slashes name the same binding. +A `bash` or `pwsh` call binds evidence only from its first result, and only when the tool execution itself succeeded; the command's exit status does not matter. A command containing `gh pr create` or `gh pr new` binds every `/pull/` URL in its text output. A command containing `git push`, optionally with leading `-C`, `-c`, or long Git options, records each branch listed under a `To ` line as created, updated, forced, or already up to date; rejected and deleted refs, new tags, and explicit refs outside `refs/heads/` are skipped. For each pushed branch whose remote a provider serves, the service runs `ctx.forge.findByHead` within `discoveryTimeoutMs` and appends a `discover` record for every returned change request that is not bound. Dismissed bindings remain bound and are skipped; only `track` restores them. Every push repeats the lookup; failures are logged as warnings. URLs that differ only in scheme, letter case, query, fragment, or trailing slashes name the same binding. Binding actions are `plugin:forge-session/binding` records listed in the [experimental persistence catalog](../../../docs/experimental-persistence-catalog.md); fork and resume carry them with the shell events, so a fork starts with its parent's bindings. `discoveryTimeoutMs` is listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge-session). @@ -85,6 +85,8 @@ None. Binding records are ignorable plugin records outside the model-visible con - **Textual command recognition** — only `bash` and `pwsh` commands containing `gh pr create`, `gh pr new`, or `git push` count, and only `/pull/` URLs bind. Scripts, aliases, `gh api` calls, other CLIs such as `glab`, and `git push --porcelain` output are not recognized; `track` binds such change requests. +- **Background output** — `run_in_background: true` returns a job id without binding evidence. A foreground command moved to the background contributes only the output in its first result; later job output does not add bindings. Use `track` for change requests missing from that result. +- **Unqualified tag updates** — a forced or up-to-date tag printed without `refs/tags/` is treated as a branch. Discovery may bind an open change request for a branch with the same name; use `dismiss` to hide an incorrect binding. - **Push-triggered discovery** — a change request opened elsewhere after the latest push appears only after another push or a `track`. - **Host API only** — no client view or model tool reads the bindings yet. - **Per-Session bindings** — subagent Sessions keep their own bindings; a parent Session does not aggregate them. diff --git a/packages/experimental/forge-session/README.zh.md b/packages/experimental/forge-session/README.zh.md index 679ec50c96..e723f623e0 100644 --- a/packages/experimental/forge-session/README.zh.md +++ b/packages/experimental/forge-session/README.zh.md @@ -36,7 +36,7 @@ kind: "package-reference" `ctx.forgeSession.changeRequests(session)` 按首次记录的顺序返回可见的绑定。每一项包含 URL、其 `origin`(`created`、`discovered` 或 `tracked`)、提供方引用(没有提供方服务该 URL 时为 `null`)以及最近一次轮询结果(尚无结果时为 `null`)。`watch(session, listener)` 是调用方 fiber 上的 effect:Session 有监听方期间,提供方服务的每个可见绑定都会被轮询;此后每次绑定变化或轮询结果到达时,监听方都会收到完整列表。`track(session, url)` 以提供方的规范形式绑定 URL,或恢复已隐藏的绑定;没有提供方识别该 URL 时抛错。`dismiss(session, url)` 隐藏一个可见绑定;其他 URL 不会改变日志。 -`bash` 或 `pwsh` 调用只从其第一个结果中提取证据,且仅当工具执行本身成功时才提取;命令的退出状态不影响结果。包含 `gh pr create` 或 `gh pr new` 的命令会绑定其文本输出中的每个 `/pull/` URL。包含 `git push` 的命令(可带前置的 `-C`、`-c` 或 Git 长选项)会记录 `To ` 行下列出的每个已创建、已更新、已强制推送或已是最新的分支;被拒绝、已删除、标签以及非分支的引用会被跳过。对远端由提供方服务的每个被推送分支,服务在 `discoveryTimeoutMs` 内调用 `ctx.forge.findByHead`,并为返回的每个尚未绑定(包括已隐藏)的变更请求追加一条 `discover` 记录。每次推送都会重新查找;失败记录为警告日志。仅在协议、大小写、查询、片段或末尾斜杠上不同的 URL 指向同一个绑定。 +`bash` 或 `pwsh` 调用只从其第一个结果中提取证据,且仅当工具执行本身成功时才提取;命令的退出状态不影响结果。包含 `gh pr create` 或 `gh pr new` 的命令会绑定其文本输出中的每个 `/pull/` URL。包含 `git push` 的命令(可带前置的 `-C`、`-c` 或 Git 长选项)会记录 `To ` 行下列出的每个已创建、已更新、已强制推送或已是最新的分支;被拒绝、已删除的引用、新标签,以及明确位于 `refs/heads/` 之外的引用会被跳过。对远端由提供方服务的每个被推送分支,服务在 `discoveryTimeoutMs` 内调用 `ctx.forge.findByHead`,并为返回的每个尚未绑定的变更请求追加一条 `discover` 记录。已隐藏的绑定仍视为已绑定而被跳过;只有 `track` 会恢复它们。每次推送都会重新查找;失败记录为警告日志。仅在协议、大小写、查询、片段或末尾斜杠上不同的 URL 指向同一个绑定。 绑定操作是 `plugin:forge-session/binding` 插件记录,列于[实验性持久化目录](../../../docs/experimental-persistence-catalog.zh.md);fork 与恢复会把它们与 shell 事件一同带上,因此 fork 出的 Session 从父 Session 的绑定开始。`discoveryTimeoutMs` 见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge-session)。 @@ -85,6 +85,8 @@ kind: "package-reference" - **按文本识别命令**——只有包含 `gh pr create`、`gh pr new` 或 `git push` 的 `bash` 与 `pwsh` 命令计入,也只有 `/pull/` URL 会被绑定。脚本、别名、`gh api` 调用、`glab` 等其他 CLI 以及 `git push --porcelain` 输出都无法识别;这类变更请求需用 `track` 绑定。 +- **后台输出**——`run_in_background: true` 只返回作业 id,不含绑定证据。转入后台的前台命令只贡献首个结果中的输出;后续作业输出不会补充绑定。首个结果中未出现的变更请求需用 `track` 绑定。 +- **未带完整引用的标签更新**——强制更新或已是最新的标签,如果输出未带 `refs/tags/`,会被当成分支。发现过程可能绑定同名分支的打开状态变更请求;可用 `dismiss` 隐藏错误绑定。 - **仅由推送触发发现**——最近一次推送之后在别处打开的变更请求,要等到下一次推送或 `track` 才会出现。 - **只有宿主 API**——目前还没有客户端视图或模型工具读取这些绑定。 - **按 Session 绑定**——subagent 的 Session 保有自己的绑定;父 Session 不会汇总它们。 diff --git a/packages/experimental/forge-session/src/commands.ts b/packages/experimental/forge-session/src/commands.ts index e95eedba87..cfc6d8d282 100644 --- a/packages/experimental/forge-session/src/commands.ts +++ b/packages/experimental/forge-session/src/commands.ts @@ -62,8 +62,9 @@ export function changeRequestUrls(text: string): string[] { /** * Find branches that `git push` output reports as created, updated, forced, * or already up to date. Each ref line belongs to the nearest preceding - * `To ` line. Rejected and deleted refs, tags, and refs outside - * `refs/heads/` are excluded. + * `To ` line. Rejected and deleted refs, new tags, and explicit refs + * outside `refs/heads/` are excluded. Unqualified tag updates cannot be + * distinguished from branch updates and are included. * @param text - rendered shell result, stdout and stderr. * @returns distinct pushed branches in order of first appearance. */ diff --git a/packages/experimental/forge/README.i18n.yaml b/packages/experimental/forge/README.i18n.yaml index 2faa7a070f..ae4a46345e 100644 --- a/packages/experimental/forge/README.i18n.yaml +++ b/packages/experimental/forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge/use-this-package: - en: bbb605854ab9590d - zh: 5b7426a09b125569 + en: 9bc1c89b6c9a3130 + zh: 196f7bb36c99743a /deepseek-ai-dsh-experimental-forge/understand-the-implementation: en: e193cb547c47069b zh: faa517522402ea41 diff --git a/packages/experimental/forge/README.md b/packages/experimental/forge/README.md index 0336a19e85..c19b2a1d25 100644 --- a/packages/experimental/forge/README.md +++ b/packages/experimental/forge/README.md @@ -34,7 +34,9 @@ Mount the registry with at least one provider package, such as the GitHub provid `ctx.forge.register(provider)` is an effect on the calling fiber and returns its disposer. One provider serves one forge family; registering a second provider for the same family throws. `repositoryFromRemote(url)` and `changeRequestFromUrl(url)` ask providers in registration order and return the first match. `changeRequestUrl(ref)` and `findByHead(repository, branch, signal)` delegate to the provider of the reference's forge and throw when none is registered. -`ctx.forge.watch(ref, listener)` is also an effect. The first watcher of a change request polls it at once; `latest(ref)` returns the most recent result. Each forge has at most one fetch in flight, and that fetch includes every due change request of the forge. An open change request is polled every `activeIntervalMs` while checks are pending or mergeability is unknown, and every `idleIntervalMs` otherwise. Closed and merged change requests are not polled again. A failed read repeats after `activeIntervalMs` doubled for each consecutive failure, up to `maxBackoffMs`. A fetch that exceeds `fetchTimeoutMs` settles as an `unavailable` failure whose message names `FORGE_FETCH_TIMEOUT`. Unregistering a provider settles its watched change requests as `unavailable`. Polling stops when the last watcher leaves. The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge) lists the defaults. +`ctx.forge.watch(ref, listener)` is also an effect. The first watcher of a change request polls it at once; `latest(ref)` returns the most recent result. Each forge has at most one fetch in flight, and that fetch includes every due change request of the forge. An open change request is polled every `activeIntervalMs` while checks are pending or mergeability is unknown, and every `idleIntervalMs` otherwise. Closed and merged change requests are not polled again. A failed read repeats after `activeIntervalMs` doubled for each consecutive failure, up to `maxBackoffMs`. Unregistering a provider settles its watched change requests as `unavailable`. Polling stops when the last watcher leaves. The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge) lists the defaults. + +Reaching `fetchTimeoutMs` aborts the fetch signal with `FORGE_FETCH_TIMEOUT`. Providers must stop their work and settle when the signal aborts; rejected fetches become `unavailable` failures. A provider that ignores cancellation can stall polling and service disposal. ----- diff --git a/packages/experimental/forge/README.zh.md b/packages/experimental/forge/README.zh.md index 532b17860d..f80ae80f17 100644 --- a/packages/experimental/forge/README.zh.md +++ b/packages/experimental/forge/README.zh.md @@ -34,7 +34,9 @@ kind: "package-reference" `ctx.forge.register(provider)` 是调用方 fiber 上的 effect,返回对应的 disposer。一个提供方服务一个 forge 家族;为同一家族注册第二个提供方会抛错。`repositoryFromRemote(url)` 与 `changeRequestFromUrl(url)` 按注册顺序询问提供方并返回第一个匹配结果。`changeRequestUrl(ref)` 与 `findByHead(repository, branch, signal)` 委托给引用所属 forge 的提供方;没有注册提供方时抛错。 -`ctx.forge.watch(ref, listener)` 同样是 effect。变更请求的第一个监听方会立即触发轮询;`latest(ref)` 返回最近一次结果。每个 forge 同时最多只有一次读取在进行,这次读取包含该 forge 所有到期的变更请求。打开状态的变更请求在检查未完成或可合并性未知时每 `activeIntervalMs` 轮询一次,否则每 `idleIntervalMs` 轮询一次。已关闭和已合并的变更请求不再轮询。读取失败后,重试间隔为 `activeIntervalMs` 按连续失败次数翻倍,最长 `maxBackoffMs`。读取超过 `fetchTimeoutMs` 时,以消息中包含 `FORGE_FETCH_TIMEOUT` 的 `unavailable` 失败结束。注销提供方会让其被监听的变更请求以 `unavailable` 结束。最后一个监听方离开后轮询停止。默认值见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge)。 +`ctx.forge.watch(ref, listener)` 同样是 effect。变更请求的第一个监听方会立即触发轮询;`latest(ref)` 返回最近一次结果。每个 forge 同时最多只有一次读取在进行,这次读取包含该 forge 所有到期的变更请求。打开状态的变更请求在检查未完成或可合并性未知时每 `activeIntervalMs` 轮询一次,否则每 `idleIntervalMs` 轮询一次。已关闭和已合并的变更请求不再轮询。读取失败后,重试间隔为 `activeIntervalMs` 按连续失败次数翻倍,最长 `maxBackoffMs`。注销提供方会让其被监听的变更请求以 `unavailable` 结束。最后一个监听方离开后轮询停止。默认值见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge)。 + +达到 `fetchTimeoutMs` 时,读取信号会以 `FORGE_FETCH_TIMEOUT` 中止。提供方必须在信号中止时停止工作并结束请求;被拒绝的读取会转为 `unavailable` 失败。忽略取消的提供方可能阻塞轮询和服务销毁。 ----- diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1b64b1fcda..295ce0e8d4 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2532,6 +2532,46 @@ "symbol": "ForgeProvider", "source": "packages/experimental/forge/src/types.ts" }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ChangeRequestKey", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ChangeRequestState", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "CheckState", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ChangeRequestCheck", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ChecksRollup", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "Mergeability", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ReviewDecision", + "source": "packages/experimental/forge/src/types.ts" + }, + { + "doc": "docs/subsystems/forge.md", + "symbol": "ForgeFailure", + "source": "packages/experimental/forge/src/types.ts" + }, { "doc": "docs/subsystems/forge.md", "symbol": "ChangeRequestSnapshot", From cba0128268f44afa4f41406d1661172f61deb865 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 17:42:09 +0800 Subject: [PATCH 02/10] fix(forge): drain provider calls before unregistering --- docs/subsystems/forge.md | 7 +- docs/subsystems/forge.zh.md | 7 +- packages/experimental/forge/README.i18n.yaml | 4 +- packages/experimental/forge/README.md | 2 +- packages/experimental/forge/README.zh.md | 2 +- packages/experimental/forge/src/index.ts | 75 +++++++++++++---- .../forge/tests/lifecycle.spec.ts | 82 +++++++++++++++++++ .../experimental/forge/tests/registry.spec.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 6 +- 9 files changed, 156 insertions(+), 31 deletions(-) create mode 100644 packages/experimental/forge/tests/lifecycle.spec.ts diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md index 841577d257..2c1d1db11e 100644 --- a/docs/subsystems/forge.md +++ b/docs/subsystems/forge.md @@ -240,12 +240,13 @@ Registry of forge providers. Watchers of one change request share a single polli /** * Register the provider of one forge family as an effect on the calling * fiber. Watches of that forge receive an `unavailable` failure when the - * registration ends. + * registration ends. Unregistering cancels and awaits its in-flight calls; + * results from the withdrawn registration are discarded. * @param provider - provider to serve `provider.forge`. - * @returns the disposer that unregisters the provider. + * @returns the disposer that unregisters the provider and waits for its calls to finish. * @throws when another provider already serves the same forge. */ -register(provider: ForgeProvider): () => void +register(provider: ForgeProvider): () => Promise /** * Map a Git remote URL to a repository through the first provider that serves it. diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md index 97555d1e05..d14d6a1f43 100644 --- a/docs/subsystems/forge.zh.md +++ b/docs/subsystems/forge.zh.md @@ -240,12 +240,13 @@ Registry of forge providers. Watchers of one change request share a single polli /** * Register the provider of one forge family as an effect on the calling * fiber. Watches of that forge receive an `unavailable` failure when the - * registration ends. + * registration ends. Unregistering cancels and awaits its in-flight calls; + * results from the withdrawn registration are discarded. * @param provider - provider to serve `provider.forge`. - * @returns the disposer that unregisters the provider. + * @returns the disposer that unregisters the provider and waits for its calls to finish. * @throws when another provider already serves the same forge. */ -register(provider: ForgeProvider): () => void +register(provider: ForgeProvider): () => Promise /** * Map a Git remote URL to a repository through the first provider that serves it. diff --git a/packages/experimental/forge/README.i18n.yaml b/packages/experimental/forge/README.i18n.yaml index ae4a46345e..ed4391fd31 100644 --- a/packages/experimental/forge/README.i18n.yaml +++ b/packages/experimental/forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge/use-this-package: - en: 9bc1c89b6c9a3130 - zh: 196f7bb36c99743a + en: 341eeed1ab1b3e82 + zh: 0b97ec8bb557a6c9 /deepseek-ai-dsh-experimental-forge/understand-the-implementation: en: e193cb547c47069b zh: faa517522402ea41 diff --git a/packages/experimental/forge/README.md b/packages/experimental/forge/README.md index c19b2a1d25..b8388c9371 100644 --- a/packages/experimental/forge/README.md +++ b/packages/experimental/forge/README.md @@ -32,7 +32,7 @@ Mount the registry with at least one provider package, such as the GitHub provid - name: '@deepseek-ai/dsh-experimental-forge-github' ``` -`ctx.forge.register(provider)` is an effect on the calling fiber and returns its disposer. One provider serves one forge family; registering a second provider for the same family throws. `repositoryFromRemote(url)` and `changeRequestFromUrl(url)` ask providers in registration order and return the first match. `changeRequestUrl(ref)` and `findByHead(repository, branch, signal)` delegate to the provider of the reference's forge and throw when none is registered. +`ctx.forge.register(provider)` is an effect on the calling fiber and returns an async disposer that cancels and drains that registration's calls. Results arriving after cancellation are discarded. One provider serves one forge family; registering a second provider for the same family throws. `repositoryFromRemote(url)` and `changeRequestFromUrl(url)` ask providers in registration order and return the first match. `changeRequestUrl(ref)` and `findByHead(repository, branch, signal)` delegate to the provider of the reference's forge and throw when none is registered. `ctx.forge.watch(ref, listener)` is also an effect. The first watcher of a change request polls it at once; `latest(ref)` returns the most recent result. Each forge has at most one fetch in flight, and that fetch includes every due change request of the forge. An open change request is polled every `activeIntervalMs` while checks are pending or mergeability is unknown, and every `idleIntervalMs` otherwise. Closed and merged change requests are not polled again. A failed read repeats after `activeIntervalMs` doubled for each consecutive failure, up to `maxBackoffMs`. Unregistering a provider settles its watched change requests as `unavailable`. Polling stops when the last watcher leaves. The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge) lists the defaults. diff --git a/packages/experimental/forge/README.zh.md b/packages/experimental/forge/README.zh.md index f80ae80f17..c6f915549c 100644 --- a/packages/experimental/forge/README.zh.md +++ b/packages/experimental/forge/README.zh.md @@ -32,7 +32,7 @@ kind: "package-reference" - name: '@deepseek-ai/dsh-experimental-forge-github' ``` -`ctx.forge.register(provider)` 是调用方 fiber 上的 effect,返回对应的 disposer。一个提供方服务一个 forge 家族;为同一家族注册第二个提供方会抛错。`repositoryFromRemote(url)` 与 `changeRequestFromUrl(url)` 按注册顺序询问提供方并返回第一个匹配结果。`changeRequestUrl(ref)` 与 `findByHead(repository, branch, signal)` 委托给引用所属 forge 的提供方;没有注册提供方时抛错。 +`ctx.forge.register(provider)` 是调用方 fiber 上的 effect,返回异步 disposer,取消并等待该次注册的调用结束。取消后到达的结果会被丢弃。一个提供方服务一个 forge 家族;为同一家族注册第二个提供方会抛错。`repositoryFromRemote(url)` 与 `changeRequestFromUrl(url)` 按注册顺序询问提供方并返回第一个匹配结果。`changeRequestUrl(ref)` 与 `findByHead(repository, branch, signal)` 委托给引用所属 forge 的提供方;没有注册提供方时抛错。 `ctx.forge.watch(ref, listener)` 同样是 effect。变更请求的第一个监听方会立即触发轮询;`latest(ref)` 返回最近一次结果。每个 forge 同时最多只有一次读取在进行,这次读取包含该 forge 所有到期的变更请求。打开状态的变更请求在检查未完成或可合并性未知时每 `activeIntervalMs` 轮询一次,否则每 `idleIntervalMs` 轮询一次。已关闭和已合并的变更请求不再轮询。读取失败后,重试间隔为 `activeIntervalMs` 按连续失败次数翻倍,最长 `maxBackoffMs`。注销提供方会让其被监听的变更请求以 `unavailable` 结束。最后一个监听方离开后轮询停止。默认值见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge)。 diff --git a/packages/experimental/forge/src/index.ts b/packages/experimental/forge/src/index.ts index 8a76e34b92..2096795a3e 100644 --- a/packages/experimental/forge/src/index.ts +++ b/packages/experimental/forge/src/index.ts @@ -21,6 +21,12 @@ declare module '@deepseek-ai/cordis' { type FetchResults = ReadonlyMap +interface Registration { + readonly provider: ForgeProvider + readonly lifetime: AbortController + readonly pending: Set> +} + interface Watch { readonly key: ChangeRequestKey readonly ref: ChangeRequestRef @@ -45,7 +51,7 @@ export class ForgeService extends Service { }) private readonly config: Required - private readonly providers = new Map() + private readonly providers = new Map() private readonly watches = new Map() private readonly fetching = new Set() private readonly pending = new Set>() @@ -82,24 +88,28 @@ export class ForgeService extends Service { /** * Register the provider of one forge family as an effect on the calling * fiber. Watches of that forge receive an `unavailable` failure when the - * registration ends. + * registration ends. Unregistering cancels and awaits its in-flight calls; + * results from the withdrawn registration are discarded. * @param provider - provider to serve `provider.forge`. - * @returns the disposer that unregisters the provider. + * @returns the disposer that unregisters the provider and waits for its calls to finish. * @throws when another provider already serves the same forge. */ - register(provider: ForgeProvider): () => void { + register(provider: ForgeProvider): () => Promise { const dispose = this.ctx.effect(() => { if (this.providers.has(provider.forge)) throw new Error(`forge: a provider for "${provider.forge}" is already registered`) - this.providers.set(provider.forge, provider) - return () => { + const registration: Registration = { provider, lifetime: new AbortController(), pending: new Set() } + this.providers.set(provider.forge, registration) + return async () => { this.providers.delete(provider.forge) - for (const watch of this.watches.values()) { + registration.lifetime.abort(new Error(`forge: provider "${provider.forge}" was unregistered`)) + for (const watch of [...this.watches.values()]) { if (watch.ref.repository.forge === provider.forge) this.settle(watch, this.missing(provider.forge)) } this.schedule() + await Promise.allSettled(registration.pending) } }, `forge.register(${provider.forge})`) - return () => void dispose() + return async () => { await dispose() } } /** @@ -108,7 +118,7 @@ export class ForgeService extends Service { * @returns the repository, or undefined when no provider serves the URL. */ repositoryFromRemote(url: string): ForgeRepository | undefined { - for (const provider of this.providers.values()) { + for (const { provider } of this.providers.values()) { const repository = provider.repositoryFromRemote(url) if (repository !== undefined) return repository } @@ -121,7 +131,7 @@ export class ForgeService extends Service { * @returns the change request, or undefined when no provider serves the URL. */ changeRequestFromUrl(url: string): ChangeRequestRef | undefined { - for (const provider of this.providers.values()) { + for (const { provider } of this.providers.values()) { const ref = provider.changeRequestFromUrl(url) if (ref !== undefined) return ref } @@ -147,7 +157,8 @@ export class ForgeService extends Service { * @throws when no provider serves the repository's forge or the lookup fails. */ async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise { - return await this.provider(repository.forge).findByHead(repository, branch, signal) + return await this.invoke(this.registration(repository.forge), signal, + (provider, operation) => provider.findByHead(repository, branch, operation)) } /** @@ -191,9 +202,34 @@ export class ForgeService extends Service { } private provider(forge: string): ForgeProvider { - const provider = this.providers.get(forge) - if (provider === undefined) throw new Error(`forge: ${this.missing(forge).message}`) - return provider + return this.registration(forge).provider + } + + private registration(forge: string): Registration { + const registration = this.providers.get(forge) + if (registration === undefined) throw new Error(`forge: ${this.missing(forge).message}`) + return registration + } + + private async invoke( + registration: Registration, + signal: AbortSignal, + action: (provider: ForgeProvider, signal: AbortSignal) => Promise, + ): Promise { + const operation = AbortSignal.any([signal, this.lifetime.signal, registration.lifetime.signal]) + operation.throwIfAborted() + const completion = Promise.withResolvers() + registration.pending.add(completion.promise) + this.pending.add(completion.promise) + try { + const result = await action(registration.provider, operation) + operation.throwIfAborted() + return result + } finally { + registration.pending.delete(completion.promise) + this.pending.delete(completion.promise) + completion.resolve() + } } private missing(forge: string): ForgeFailure { @@ -237,13 +273,14 @@ export class ForgeService extends Service { for (const watch of batch) watch.dueAt = undefined let results: FetchResults | undefined let failure: ForgeFailure | undefined + const registration = this.providers.get(forge) try { - const provider = this.providers.get(forge) - if (provider === undefined) { + if (registration === undefined) { failure = this.missing(forge) } else { using timeout = deadline(this.lifetime.signal, this.config.fetchTimeoutMs, 'FORGE_FETCH_TIMEOUT') - results = await provider.fetch(batch.map(watch => watch.ref), timeout.signal) + results = await this.invoke(registration, timeout.signal, + (provider, operation) => provider.fetch(batch.map(watch => watch.ref), operation)) } } catch (error) { failure = { code: 'unavailable', message: error instanceof Error ? error.message : String(error) } @@ -251,6 +288,10 @@ export class ForgeService extends Service { this.fetching.delete(forge) } if (this.lifetime.signal.aborted) return + if (registration !== this.providers.get(forge)) { + this.schedule() + return + } for (const watch of batch) { if (this.watches.get(watch.key) !== watch) continue const result = failure ?? results?.get(watch.key) ?? { code: 'unavailable', message: `provider returned no result for ${watch.key}` } diff --git a/packages/experimental/forge/tests/lifecycle.spec.ts b/packages/experimental/forge/tests/lifecycle.spec.ts new file mode 100644 index 0000000000..c2c67f2a68 --- /dev/null +++ b/packages/experimental/forge/tests/lifecycle.spec.ts @@ -0,0 +1,82 @@ +import { afterEach, expect, it, vi } from 'vitest' +import type { Context } from '@deepseek-ai/cordis' +import type { ChangeRequestUpdate, ForgeProvider } from '../src/types.ts' +import { changeRequestKey } from '../src/index.ts' +import { demo, FakeProvider, forgeContext, provide, ref, snapshot } from './fake-provider.ts' + +let ctx: Context | undefined + +afterEach(async () => { + await ctx?.fiber.dispose() + ctx = undefined + vi.useRealTimers() +}) + +it('cancels and drains a provider fetch before its fiber finishes unloading', async () => { + vi.useFakeTimers() + ctx = await forgeContext() + const provider = new FakeProvider() + const result = Promise.withResolvers>>() + const aborted = Promise.withResolvers() + provider.fetchImpl = (_refs, signal) => { + signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) + return result.promise + } + const fiber = await provide(ctx, provider) + const updates: ChangeRequestUpdate[] = [] + ctx.forge.watch(ref(1), (update) => { updates.push(update) }) + let disposed = false + try { + await vi.advanceTimersByTimeAsync(0) + const disposal = fiber.dispose().then(() => { disposed = true }) + await aborted.promise + expect(disposed).toBe(false) + expect(provider.signals[0]?.aborted).toBe(true) + expect(updates).toHaveLength(1) + expect(updates[0]).toMatchObject({ failure: { code: 'unavailable' } }) + + result.resolve(new Map([[changeRequestKey(ref(1)), snapshot(ref(1), { state: 'closed' })]])) + await disposal + await vi.advanceTimersByTimeAsync(0) + expect(updates).toHaveLength(1) + expect(ctx.forge.latest(ref(1))).toMatchObject({ failure: { code: 'unavailable' } }) + } finally { + result.resolve(new Map()) + } +}) + +it.each(['provider', 'service'] as const)('cancels and drains head lookups when the %s stops', async (owner) => { + ctx = await forgeContext() + const provider: ForgeProvider = new FakeProvider() + const result = Promise.withResolvers>>() + const aborted = Promise.withResolvers() + vi.spyOn(provider, 'findByHead').mockImplementation((_repository, _branch, signal) => { + signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) + return result.promise + }) + const fiber = await provide(ctx, provider) + const lookup = ctx.forge.findByHead(demo, 'topic', new AbortController().signal) + const rejected = expect(lookup).rejects.toThrow(owner === 'provider' ? 'was unregistered' : /disposed|unregistered/) + let disposed = false + try { + const disposal = (owner === 'provider' ? fiber : ctx.fiber).dispose().then(() => { disposed = true }) + await aborted.promise + expect(disposed).toBe(false) + result.resolve([ref(3)]) + await rejected + await disposal + expect(disposed).toBe(true) + } finally { + result.resolve([]) + await rejected + } +}) + +it('does not invoke a provider for an already cancelled lookup', async () => { + ctx = await forgeContext() + const provider = new FakeProvider() + const lookup = vi.spyOn(provider, 'findByHead') + await provide(ctx, provider) + await expect(ctx.forge.findByHead(demo, 'topic', AbortSignal.abort(new Error('caller stopped')))).rejects.toThrow('caller stopped') + expect(lookup).not.toHaveBeenCalled() +}) diff --git a/packages/experimental/forge/tests/registry.spec.ts b/packages/experimental/forge/tests/registry.spec.ts index 4258047a62..3b97fa0dea 100644 --- a/packages/experimental/forge/tests/registry.spec.ts +++ b/packages/experimental/forge/tests/registry.spec.ts @@ -65,7 +65,7 @@ describe('forge registry', () => { const unregister = ctx.forge.register(new FakeProvider()) expect(ctx.forge.repositoryFromRemote('fake://fake.test/Octo/Demo')).toBeDefined() - unregister() + await unregister() expect(ctx.forge.repositoryFromRemote('fake://fake.test/Octo/Demo')).toBeUndefined() }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 44a5031d33..bbe7cd9ac3 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1224,10 +1224,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Registry of forge providers. Watchers of one change request share a single polling loop; each forge has at most one fetch in flight, which batches every due change request of that forge.', methods: [ { - signature: 'register(provider: ForgeProvider): () => void', - description: 'Register the provider of one forge family as an effect on the calling fiber. Watches of that forge receive an `unavailable` failure when the registration ends.', + signature: 'register(provider: ForgeProvider): () => Promise', + description: 'Register the provider of one forge family as an effect on the calling fiber. Watches of that forge receive an `unavailable` failure when the registration ends. Unregistering cancels and awaits its in-flight calls; results from the withdrawn registration are discarded.', parameters: [{ name: 'provider', description: 'provider to serve `provider.forge`.' }], - returns: 'the disposer that unregisters the provider.', + returns: 'the disposer that unregisters the provider and waits for its calls to finish.', throws: ['when another provider already serves the same forge.'], }, { From fbcfd80e21ebdbba7645b563dd70bf5295966d0a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 17:42:35 +0800 Subject: [PATCH 03/10] fix(forge-github): enforce configured hosts before queries --- .../forge-github/README.i18n.yaml | 4 ++-- packages/experimental/forge-github/README.md | 2 +- .../experimental/forge-github/README.zh.md | 2 +- .../experimental/forge-github/src/provider.ts | 15 ++++++++++++++- .../forge-github/tests/provider.spec.ts | 19 +++++++++++++++++++ 5 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/experimental/forge-github/README.i18n.yaml b/packages/experimental/forge-github/README.i18n.yaml index aa17a574ec..1da77a0a83 100644 --- a/packages/experimental/forge-github/README.i18n.yaml +++ b/packages/experimental/forge-github/README.i18n.yaml @@ -30,8 +30,8 @@ en: 26b946af4b742e17 zh: 00b34f0721ac72f6 /deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work: - en: 441928a7cc59da3b - zh: 96496453ce349cad + en: eb7d8000ca55baca + zh: 27ba101ffa91175b /deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/forge-github/README.md b/packages/experimental/forge-github/README.md index 0e95b7844a..8eab8d204b 100644 --- a/packages/experimental/forge-github/README.md +++ b/packages/experimental/forge-github/README.md @@ -84,7 +84,7 @@ None. Provider output reaches only host-side watchers. -- **GitHub CLI on the host** — `gh` must be installed and signed in for each configured host; tokens in environment variables are not forwarded. Hosts missing from `hosts` are never read. +- **GitHub CLI on the host** — `gh` must be installed and signed in for each configured host; tokens in environment variables are not forwarded. Hosts missing from `hosts` are never read, including references retained across configuration changes. A status fetch returns `unavailable` for those references; a head lookup rejects before starting `gh`. - **First 100 checks and review threads** — `checksTruncated` marks a head commit with more checks; unresolved threads after the first 100 are not counted. - **No fork discovery** — head lookups exclude pull requests whose head is in another repository. - **Rate limits** — every poll costs one GraphQL query per host against the signed-in account's rate limit. diff --git a/packages/experimental/forge-github/README.zh.md b/packages/experimental/forge-github/README.zh.md index a8e1aa7e75..6ff9b9ea6b 100644 --- a/packages/experimental/forge-github/README.zh.md +++ b/packages/experimental/forge-github/README.zh.md @@ -84,7 +84,7 @@ kind: "package-reference" -- **宿主需要 GitHub CLI**——必须安装 `gh` 并为每个已配置主机登录;环境变量中的令牌不会被转发。不在 `hosts` 中的主机永远不会被读取。 +- **宿主需要 GitHub CLI**——必须安装 `gh` 并为每个已配置主机登录;环境变量中的令牌不会被转发。不在 `hosts` 中的主机永远不会被读取,包括配置变更后保留的引用。状态读取为这些引用返回 `unavailable`;head 查找会在启动 `gh` 前拒绝。 - **前 100 个检查与评审讨论**——head 提交的检查超过上限时以 `checksTruncated` 标记;前 100 个之后的未解决讨论不计入。 - **不发现 fork**——按 head 分支查找时,不包含 head 位于其他仓库的 pull request。 - **速率限制**——每次轮询都会以已登录账号的速率配额为每个主机消耗一次 GraphQL 查询。 diff --git a/packages/experimental/forge-github/src/provider.ts b/packages/experimental/forge-github/src/provider.ts index b5607eb44d..982b08dbb4 100644 --- a/packages/experimental/forge-github/src/provider.ts +++ b/packages/experimental/forge-github/src/provider.ts @@ -45,12 +45,17 @@ export class GitHubProvider implements ForgeProvider { signal: AbortSignal, ): Promise> { const byHost = new Map() + const results = new Map() for (const ref of refs) { + const failure = this.hostFailure(ref.repository.host) + if (failure !== undefined) { + results.set(changeRequestKey(ref), failure) + continue + } const group = byHost.get(ref.repository.host) if (group === undefined) byHost.set(ref.repository.host, [ref]) else group.push(ref) } - const results = new Map() for (const [host, group] of byHost) { for (const [key, result] of await this.fetchHost(host, group, signal)) results.set(key, result) } @@ -58,6 +63,8 @@ export class GitHubProvider implements ForgeProvider { } async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise { + const failure = this.hostFailure(repository.host) + if (failure !== undefined) throw new Error(failure.message) const output = await this.gh([ 'api', 'graphql', '--hostname', repository.host, '-f', `query=${HEAD_QUERY}`, '-f', `owner=${repository.owner}`, '-f', `name=${repository.name}`, '-f', `branch=${branch}`, @@ -67,6 +74,12 @@ export class GitHubProvider implements ForgeProvider { return mapHeadResponse(body, repository, branch) } + private hostFailure(host: string): ForgeFailure | undefined { + return this.hosts.has(host.toLowerCase()) ? undefined : { + code: 'unavailable', message: `forge-github: host ${JSON.stringify(host)} is not configured`, + } + } + private async fetchHost( host: string, refs: readonly ChangeRequestRef[], diff --git a/packages/experimental/forge-github/tests/provider.spec.ts b/packages/experimental/forge-github/tests/provider.spec.ts index 86fa5db463..e16f972a3b 100644 --- a/packages/experimental/forge-github/tests/provider.spec.ts +++ b/packages/experimental/forge-github/tests/provider.spec.ts @@ -45,6 +45,25 @@ describe('GitHub provider addressing', () => { }) describe('GitHub provider fetch', () => { + it('rejects unconfigured hosts without querying them and still fetches allowed hosts', async () => { + const gh = fakeGh(() => output({ data: { r0: { p0: pullRequestNode(1) } } })) + const denied = pull(2, { ...enterprise, host: 'unconfigured.example' }) + const results = await provider(gh.run).fetch([denied, pull(1)], signal) + expect(gh.calls.map(call => call[3])).toEqual(['github.com']) + expect(results.get(changeRequestKey(denied))).toEqual({ code: 'unavailable', message: 'forge-github: host "unconfigured.example" is not configured' }) + expect(results.get(changeRequestKey(pull(1)))).toMatchObject({ title: 'Change 1' }) + }) + + it('rejects references retained after their host was removed from configuration', async () => { + const gh = fakeGh(() => output('')) + const retained = provider(gh.run).changeRequestFromUrl('https://ghe.corp.example/team/svc/pull/2')! + const replacement = new GitHubProvider(gh.run, ['github.com']) + const results = await replacement.fetch([retained], signal) + await expect(replacement.findByHead(retained.repository, 'topic', signal)).rejects.toThrow('is not configured') + expect(results.get(changeRequestKey(retained))).toMatchObject({ code: 'unavailable' }) + expect(gh.calls).toEqual([]) + }) + it('runs one GraphQL request per host and merges the results', async () => { const gh = fakeGh((args) => { const host = args[3] From 3f53fe39fb281fd99f8a7124de074668a1a612eb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 17:43:59 +0800 Subject: [PATCH 04/10] fix(forge-session): cancel discovery when Sessions close --- .../forge-session/README.i18n.yaml | 4 +- packages/experimental/forge-session/README.md | 2 +- .../experimental/forge-session/README.zh.md | 2 +- .../experimental/forge-session/src/index.ts | 23 +++++++--- .../forge-session/tests/service.spec.ts | 43 +++++++++++++++++++ 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/packages/experimental/forge-session/README.i18n.yaml b/packages/experimental/forge-session/README.i18n.yaml index b0b2696097..1dd3eaaf22 100644 --- a/packages/experimental/forge-session/README.i18n.yaml +++ b/packages/experimental/forge-session/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge-session/use-this-package: - en: 0b75d06236040dd9 - zh: ff30c83707003bca + en: 022ab6a18364aa44 + zh: adc91aef3f4273e3 /deepseek-ai-dsh-experimental-forge-session/understand-the-implementation: en: ccc7ad69f5837528 zh: 369c08bf4f7e6e4d diff --git a/packages/experimental/forge-session/README.md b/packages/experimental/forge-session/README.md index bb21b3d288..a242d1b5a5 100644 --- a/packages/experimental/forge-session/README.md +++ b/packages/experimental/forge-session/README.md @@ -36,7 +36,7 @@ Mount the service with the forge registry, a provider, and the Session projectio `ctx.forgeSession.changeRequests(session)` returns the visible bindings in first-recorded order. Each entry carries the URL, its `origin` (`created`, `discovered`, or `tracked`), the provider reference or `null` when no provider serves the URL, and the latest polling result or `null`. `watch(session, listener)` is an effect on the calling fiber: while a Session has a watcher, every visible binding that a provider serves is polled, and listeners receive the complete list after each later binding change or polling result. `track(session, url)` binds a URL under the provider's canonical form, or restores a dismissed binding, and throws when no provider recognizes the URL. `dismiss(session, url)` hides a visible binding; other URLs leave the log unchanged. -A `bash` or `pwsh` call binds evidence only from its first result, and only when the tool execution itself succeeded; the command's exit status does not matter. A command containing `gh pr create` or `gh pr new` binds every `/pull/` URL in its text output. A command containing `git push`, optionally with leading `-C`, `-c`, or long Git options, records each branch listed under a `To ` line as created, updated, forced, or already up to date; rejected and deleted refs, new tags, and explicit refs outside `refs/heads/` are skipped. For each pushed branch whose remote a provider serves, the service runs `ctx.forge.findByHead` within `discoveryTimeoutMs` and appends a `discover` record for every returned change request that is not bound. Dismissed bindings remain bound and are skipped; only `track` restores them. Every push repeats the lookup; failures are logged as warnings. URLs that differ only in scheme, letter case, query, fragment, or trailing slashes name the same binding. +A `bash` or `pwsh` call binds evidence only from its first result, and only when the tool execution itself succeeded; the command's exit status does not matter. A command containing `gh pr create` or `gh pr new` binds every `/pull/` URL in its text output. A command containing `git push`, optionally with leading `-C`, `-c`, or long Git options, records each branch listed under a `To ` line as created, updated, forced, or already up to date; rejected and deleted refs, new tags, and explicit refs outside `refs/heads/` are skipped. For each pushed branch whose remote a provider serves, the service runs `ctx.forge.findByHead` within `discoveryTimeoutMs` and appends a `discover` record for every returned change request that is not bound. Dismissed bindings remain bound and are skipped; only `track` restores them. Every push repeats the lookup; failures are logged as warnings. Closing a Session cancels its unfinished lookups without adding bindings. Reopening it does not repeat those lookups; another push or `track` can bind the change request. URLs that differ only in scheme, letter case, query, fragment, or trailing slashes name the same binding. Binding actions are `plugin:forge-session/binding` records listed in the [experimental persistence catalog](../../../docs/experimental-persistence-catalog.md); fork and resume carry them with the shell events, so a fork starts with its parent's bindings. `discoveryTimeoutMs` is listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge-session). diff --git a/packages/experimental/forge-session/README.zh.md b/packages/experimental/forge-session/README.zh.md index e723f623e0..076b876b21 100644 --- a/packages/experimental/forge-session/README.zh.md +++ b/packages/experimental/forge-session/README.zh.md @@ -36,7 +36,7 @@ kind: "package-reference" `ctx.forgeSession.changeRequests(session)` 按首次记录的顺序返回可见的绑定。每一项包含 URL、其 `origin`(`created`、`discovered` 或 `tracked`)、提供方引用(没有提供方服务该 URL 时为 `null`)以及最近一次轮询结果(尚无结果时为 `null`)。`watch(session, listener)` 是调用方 fiber 上的 effect:Session 有监听方期间,提供方服务的每个可见绑定都会被轮询;此后每次绑定变化或轮询结果到达时,监听方都会收到完整列表。`track(session, url)` 以提供方的规范形式绑定 URL,或恢复已隐藏的绑定;没有提供方识别该 URL 时抛错。`dismiss(session, url)` 隐藏一个可见绑定;其他 URL 不会改变日志。 -`bash` 或 `pwsh` 调用只从其第一个结果中提取证据,且仅当工具执行本身成功时才提取;命令的退出状态不影响结果。包含 `gh pr create` 或 `gh pr new` 的命令会绑定其文本输出中的每个 `/pull/` URL。包含 `git push` 的命令(可带前置的 `-C`、`-c` 或 Git 长选项)会记录 `To ` 行下列出的每个已创建、已更新、已强制推送或已是最新的分支;被拒绝、已删除的引用、新标签,以及明确位于 `refs/heads/` 之外的引用会被跳过。对远端由提供方服务的每个被推送分支,服务在 `discoveryTimeoutMs` 内调用 `ctx.forge.findByHead`,并为返回的每个尚未绑定的变更请求追加一条 `discover` 记录。已隐藏的绑定仍视为已绑定而被跳过;只有 `track` 会恢复它们。每次推送都会重新查找;失败记录为警告日志。仅在协议、大小写、查询、片段或末尾斜杠上不同的 URL 指向同一个绑定。 +`bash` 或 `pwsh` 调用只从其第一个结果中提取证据,且仅当工具执行本身成功时才提取;命令的退出状态不影响结果。包含 `gh pr create` 或 `gh pr new` 的命令会绑定其文本输出中的每个 `/pull/` URL。包含 `git push` 的命令(可带前置的 `-C`、`-c` 或 Git 长选项)会记录 `To ` 行下列出的每个已创建、已更新、已强制推送或已是最新的分支;被拒绝、已删除的引用、新标签,以及明确位于 `refs/heads/` 之外的引用会被跳过。对远端由提供方服务的每个被推送分支,服务在 `discoveryTimeoutMs` 内调用 `ctx.forge.findByHead`,并为返回的每个尚未绑定的变更请求追加一条 `discover` 记录。已隐藏的绑定仍视为已绑定而被跳过;只有 `track` 会恢复它们。每次推送都会重新查找;失败记录为警告日志。关闭 Session 会取消其未完成的查找,不追加绑定。重新打开不会补做这些查找;可再次推送或用 `track` 绑定变更请求。仅在协议、大小写、查询、片段或末尾斜杠上不同的 URL 指向同一个绑定。 绑定操作是 `plugin:forge-session/binding` 插件记录,列于[实验性持久化目录](../../../docs/experimental-persistence-catalog.zh.md);fork 与恢复会把它们与 shell 事件一同带上,因此 fork 出的 Session 从父 Session 的绑定开始。`discoveryTimeoutMs` 见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge-session)。 diff --git a/packages/experimental/forge-session/src/index.ts b/packages/experimental/forge-session/src/index.ts index b3e968a364..09ada55ce7 100644 --- a/packages/experimental/forge-session/src/index.ts +++ b/packages/experimental/forge-session/src/index.ts @@ -37,7 +37,7 @@ interface SessionWatch { * requests through `ctx.forge`. */ export class ForgeSessionService extends Service { - static inject = ['forge', 'sessionProjections'] + static inject = ['forge', 'sessions', 'sessionProjections'] static Config: z = z.object({ discoveryTimeoutMs: z.number().default(30_000), @@ -47,6 +47,7 @@ export class ForgeSessionService extends Service { private readonly discoveryTimeoutMs: number private readonly watches = new WeakMap() private readonly discoveries = new Set>() + private readonly sessionLifetimes = new WeakMap() private readonly lifetime = new AbortController() private projection!: ProjectionHandle @@ -139,6 +140,10 @@ export class ForgeSessionService extends Service { this.home.on('session/event', (session: Session, event: SessionEvent) => { this.react(session, event) }) + this.home.on('session/disposed', (session: Session) => { + this.sessionLifetimes.get(session)?.abort(new Error('forge-session: Session was disposed')) + this.sessionLifetimes.delete(session) + }) } private state(session: Session): ForgeChangeRequestsState { @@ -200,19 +205,25 @@ export class ForgeSessionService extends Service { private discover(session: Session, push: PushedBranch): void { const repository = this.home.forge.repositoryFromRemote(push.remote) if (repository === undefined) return - const task = this.lookup(session, repository, push.branch).catch((error: unknown) => { - if (this.lifetime.signal.aborted) return + let lifetime = this.sessionLifetimes.get(session) + if (lifetime === undefined) { + lifetime = new AbortController() + this.sessionLifetimes.set(session, lifetime) + } + const signal = AbortSignal.any([this.lifetime.signal, lifetime.signal]) + const task = this.lookup(session, repository, push.branch, signal).catch((error: unknown) => { + if (signal.aborted) return this.home.logger.warn(`forge-session: change request lookup for ${push.branch} failed: ${error instanceof Error ? error.message : String(error)}`) }) this.discoveries.add(task) void task.then(() => this.discoveries.delete(task)) } - private async lookup(session: Session, repository: ForgeRepository, branch: string): Promise { - using timeout = deadline(this.lifetime.signal, this.discoveryTimeoutMs, 'FORGE_DISCOVERY_TIMEOUT') + private async lookup(session: Session, repository: ForgeRepository, branch: string, signal: AbortSignal): Promise { + using timeout = deadline(signal, this.discoveryTimeoutMs, 'FORGE_DISCOVERY_TIMEOUT') const refs = await this.home.forge.findByHead(repository, branch, timeout.signal) - if (this.lifetime.signal.aborted) return for (const ref of refs) { + if (signal.aborted || this.home.sessions.get(session.id) !== session) return const url = this.home.forge.changeRequestUrl(ref) if (this.state(session).bindings.some(binding => sameChangeRequestUrl(binding.url, url))) continue appendPluginRecord(session, BINDING_RECORD, { action: 'discover', url }) diff --git a/packages/experimental/forge-session/tests/service.spec.ts b/packages/experimental/forge-session/tests/service.spec.ts index c244665749..10f22b0091 100644 --- a/packages/experimental/forge-session/tests/service.spec.ts +++ b/packages/experimental/forge-session/tests/service.spec.ts @@ -66,6 +66,49 @@ describe('forgeSession bindings', () => { }) describe('forgeSession discovery', () => { + it('cancels a closed Session lookup and never writes its result into a same-id replacement', async () => { + const { ctx, provider } = await open() + const owner = ctx.plugin({ inject: ['sessions'], apply() {} }) + await owner + const session = owner.ctx.sessions.create() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const result = Promise.withResolvers[]>() + provider.findImpl = () => result.promise + try { + push(session, 'push', 'feat') + expect(provider.lookups).toHaveLength(1) + await owner.dispose() + expect(provider.lookups[0]?.signal.aborted).toBe(true) + const replacement = ctx.sessions.create(session.id) + result.resolve([pull(7)]) + await settle() + expect(records(session)).toEqual([]) + expect(records(replacement)).toEqual([]) + expect(warn).not.toHaveBeenCalled() + } finally { + result.resolve([]) + } + }) + + it('stops publishing lookup results when the Session closes after the first binding', async () => { + const { ctx, provider } = await open() + const session = ctx.sessions.prepare() + const detach = ctx.sessions.enter(session) + ctx.sessions.announce(session) + ctx.on('session/event', (changed, event) => { + if (changed === session && pluginRecordOf(event)?.type === 'plugin:forge-session/binding') detach() + }) + provider.heads.set('feat', [pull(7), pull(8)]) + try { + push(session, 'push', 'feat') + await settle() + expect(records(session)).toEqual([{ action: 'discover', url: pullUrl(7) }]) + expect(ctx.sessions.get(session.id)).toBeUndefined() + } finally { + detach() + } + }) + it('records change requests found for a pushed branch', async () => { const { ctx, provider, session } = await open() provider.heads.set('feat', [pull(7)]) From ae403821970f633f7e2e3199e73ef300042da9f9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 17:45:20 +0800 Subject: [PATCH 05/10] fix(forge-github): distinguish HTTP errors from GraphQL results --- .../forge-github/README.i18n.yaml | 4 ++-- packages/experimental/forge-github/README.md | 2 +- .../experimental/forge-github/README.zh.md | 2 +- .../experimental/forge-github/src/graphql.ts | 11 +++++++++- .../experimental/forge-github/src/provider.ts | 21 ++++++++++++------- .../forge-github/tests/graphql.spec.ts | 4 ++-- .../forge-github/tests/provider.spec.ts | 14 +++++++++++++ 7 files changed, 43 insertions(+), 15 deletions(-) diff --git a/packages/experimental/forge-github/README.i18n.yaml b/packages/experimental/forge-github/README.i18n.yaml index 1da77a0a83..a38adba694 100644 --- a/packages/experimental/forge-github/README.i18n.yaml +++ b/packages/experimental/forge-github/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge-github/use-this-package: - en: b855a94e8936cded - zh: 3235478887a74764 + en: 3ce1c5d33f0abb6d + zh: 3ff3b4d8f3ed860f /deepseek-ai-dsh-experimental-forge-github/understand-the-implementation: en: 194951c8b5093f7d zh: 9c5ffaa9e531bd1f diff --git a/packages/experimental/forge-github/README.md b/packages/experimental/forge-github/README.md index 8eab8d204b..2aa4aa1e11 100644 --- a/packages/experimental/forge-github/README.md +++ b/packages/experimental/forge-github/README.md @@ -38,7 +38,7 @@ The provider runs `gh` as a host process, even when Sessions execute commands in The provider serves a remote URL in `https`, `http`, `ssh`, or `git` form, or in scp form such as `git@github.com:owner/name.git`, when its host is configured. It serves pull request URLs of the form `https://///pull/` with any trailing path, query, or fragment, and builds that form without the suffix as the canonical URL. A head lookup returns up to 20 open pull requests whose head is the branch, newest first, excluding pull requests from forks; a lookup of the default branch returns none. -Check runs map to `pending` until completed, then to `success`, `failure` (failure, timed out, startup failure, or action required), `cancelled`, `skipped`, or `neutral`. Commit status contexts map `SUCCESS` to `success`, `FAILURE` and `ERROR` to `failure`, and every other state to `pending`. A GraphQL `NOT_FOUND` error becomes a `not-found` failure. When `gh` prints no JSON, a diagnostic that mentions authentication or `gh auth login` becomes `unauthenticated` and any other diagnostic becomes `unavailable`. +Check runs map to `pending` until completed, then to `success`, `failure` (failure, timed out, startup failure, or action required), `cancelled`, `skipped`, or `neutral`. Commit status contexts map `SUCCESS` to `success`, `FAILURE` and `ERROR` to `failure`, and every other state to `pending`. A GraphQL `NOT_FOUND` error becomes a `not-found` failure. A response without GraphQL data or errors, including an HTTP error JSON body, uses the first stderr diagnostic or the body's message. Authentication diagnostics become `unauthenticated`; rate limits and other failures become `unavailable`. Head lookups preserve the same diagnostic in their rejection. ----- diff --git a/packages/experimental/forge-github/README.zh.md b/packages/experimental/forge-github/README.zh.md index 6ff9b9ea6b..821b468f72 100644 --- a/packages/experimental/forge-github/README.zh.md +++ b/packages/experimental/forge-github/README.zh.md @@ -38,7 +38,7 @@ kind: "package-reference" 当主机在配置中时,提供方服务 `https`、`http`、`ssh`、`git` 形式的远端 URL,以及 `git@github.com:owner/name.git` 这类 scp 形式。它服务 `https://///pull/` 形式的 pull request URL,允许其后附带任意路径、查询或片段,并以去掉这些后缀的形式作为规范 URL。按 head 分支查找时,最多返回 20 个以该分支为 head 的打开状态 pull request,按创建时间从新到旧排列,不包含来自 fork 的 pull request;查找默认分支时返回空结果。 -检查运行(check run)在完成前映射为 `pending`,完成后映射为 `success`、`failure`(失败、超时、启动失败或需要操作)、`cancelled`、`skipped` 或 `neutral`。提交状态上下文把 `SUCCESS` 映射为 `success`,把 `FAILURE` 与 `ERROR` 映射为 `failure`,其余状态均映射为 `pending`。GraphQL 的 `NOT_FOUND` 错误转为 `not-found` 失败。`gh` 没有输出 JSON 时,提到身份验证或 `gh auth login` 的诊断转为 `unauthenticated`,其他诊断转为 `unavailable`。 +检查运行(check run)在完成前映射为 `pending`,完成后映射为 `success`、`failure`(失败、超时、启动失败或需要操作)、`cancelled`、`skipped` 或 `neutral`。提交状态上下文把 `SUCCESS` 映射为 `success`,把 `FAILURE` 与 `ERROR` 映射为 `failure`,其余状态均映射为 `pending`。GraphQL 的 `NOT_FOUND` 错误转为 `not-found` 失败。响应不含 GraphQL data 或 errors 时(包括 HTTP 错误 JSON),使用 stderr 的首条诊断或响应体的 message。认证诊断转为 `unauthenticated`;限流与其他失败转为 `unavailable`。head 查找在拒绝时保留相同诊断。 ----- diff --git a/packages/experimental/forge-github/src/graphql.ts b/packages/experimental/forge-github/src/graphql.ts index 2f186eeb38..d69fca1807 100644 --- a/packages/experimental/forge-github/src/graphql.ts +++ b/packages/experimental/forge-github/src/graphql.ts @@ -79,7 +79,16 @@ const responseSchema = z.object({ path: z.array(z.union([z.string(), z.number()])).optional(), message: z.string(), })).optional(), -}) +}).refine(hasGraphqlFields) + +/** + * Distinguish GraphQL response fields from HTTP error bodies. + * @param body - parsed JSON returned by GitHub. + * @returns whether the object carries GraphQL data or errors; field validation remains with the query mapper. + */ +export function hasGraphqlFields(body: unknown): boolean { + return typeof body === 'object' && body !== null && ('data' in body || 'errors' in body) +} const headSchema = z.object({ data: z.object({ diff --git a/packages/experimental/forge-github/src/provider.ts b/packages/experimental/forge-github/src/provider.ts index 982b08dbb4..611b920d20 100644 --- a/packages/experimental/forge-github/src/provider.ts +++ b/packages/experimental/forge-github/src/provider.ts @@ -4,8 +4,8 @@ import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge' import type { ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeFailure, ForgeProvider, ForgeRepository, } from '@deepseek-ai/dsh-experimental-forge' -import type { GhRunner } from './gh.ts' -import { HEAD_QUERY, mapHeadResponse, mapSnapshotResponse, snapshotQuery } from './graphql.ts' +import type { GhOutput, GhRunner } from './gh.ts' +import { hasGraphqlFields, HEAD_QUERY, mapHeadResponse, mapSnapshotResponse, snapshotQuery } from './graphql.ts' import { parsePullRequestUrl, parseRemoteUrl } from './urls.ts' const AUTHENTICATION = /gh auth login|authentication|bad credentials|HTTP 401/i @@ -70,7 +70,7 @@ export class GitHubProvider implements ForgeProvider { '-f', `query=${HEAD_QUERY}`, '-f', `owner=${repository.owner}`, '-f', `name=${repository.name}`, '-f', `branch=${branch}`, ], signal) const body = parseJson(output.stdout) - if (body === undefined) throw new Error(`forge-github: head branch lookup failed: ${firstLine(output.stderr) ?? `gh exited with ${output.exitCode}`}`) + if (!hasGraphqlFields(body)) throw new Error(`forge-github: head branch lookup failed: ${responseFailure(output, body).message}`) return mapHeadResponse(body, repository, branch) } @@ -88,15 +88,20 @@ export class GitHubProvider implements ForgeProvider { const query = snapshotQuery(refs) const output = await this.gh(['api', 'graphql', '--hostname', host, '-f', `query=${query.document}`], signal) const body = parseJson(output.stdout) - if (body !== undefined) return mapSnapshotResponse(body, query) - const failure: ForgeFailure = { - code: AUTHENTICATION.test(output.stderr) ? 'unauthenticated' : 'unavailable', - message: firstLine(output.stderr) ?? `gh exited with ${output.exitCode}`, - } + if (hasGraphqlFields(body)) return mapSnapshotResponse(body, query) + const failure = responseFailure(output, body) return new Map([...query.targets.values()].map(ref => [changeRequestKey(ref), failure] as const)) } } +function responseFailure(output: GhOutput, body: unknown): ForgeFailure { + const detail = typeof body === 'object' && body !== null && 'message' in body && typeof body.message === 'string' + ? firstLine(body.message) : undefined + const message = firstLine(output.stderr) ?? detail + ?? (output.exitCode === 0 ? 'unexpected GitHub response' : `gh exited with ${output.exitCode}`) + return { code: AUTHENTICATION.test(message) ? 'unauthenticated' : 'unavailable', message } +} + function parseJson(text: string): unknown { let value: unknown try { diff --git a/packages/experimental/forge-github/tests/graphql.spec.ts b/packages/experimental/forge-github/tests/graphql.spec.ts index 3ec8c0a35f..f9ac88dc96 100644 --- a/packages/experimental/forge-github/tests/graphql.spec.ts +++ b/packages/experimental/forge-github/tests/graphql.spec.ts @@ -172,8 +172,8 @@ describe('snapshot mapping', () => { { code: 'unavailable', message: 'unexpected pull request fields' }, ]) expect([...mapSnapshotResponse({}, query).values()]).toEqual([ - { code: 'not-found', message: 'pull request not found' }, - { code: 'not-found', message: 'pull request not found' }, + { code: 'unavailable', message: 'unexpected GitHub response' }, + { code: 'unavailable', message: 'unexpected GitHub response' }, ]) expect([...mapSnapshotResponse('not json', query).values()]).toEqual([ { code: 'unavailable', message: 'unexpected GitHub response' }, diff --git a/packages/experimental/forge-github/tests/provider.spec.ts b/packages/experimental/forge-github/tests/provider.spec.ts index e16f972a3b..d80b8203f3 100644 --- a/packages/experimental/forge-github/tests/provider.spec.ts +++ b/packages/experimental/forge-github/tests/provider.spec.ts @@ -45,6 +45,20 @@ describe('GitHub provider addressing', () => { }) describe('GitHub provider fetch', () => { + it.each([ + [{ message: 'Bad credentials' }, 1, 'gh: Bad credentials (HTTP 401)', 'unauthenticated', 'gh: Bad credentials (HTTP 401)'], + [{ message: 'API rate limit exceeded' }, 1, 'gh: API rate limit exceeded (HTTP 403)', 'unavailable', 'gh: API rate limit exceeded (HTTP 403)'], + [{ message: 'Bad credentials' }, 1, '', 'unauthenticated', 'Bad credentials'], + [{ message: '' }, 0, '', 'unavailable', 'unexpected GitHub response'], + [null, 1, '', 'unavailable', 'gh exited with 1'], + [[], 0, '', 'unavailable', 'unexpected GitHub response'], + ])('preserves HTTP failure diagnostics for %j', async (body, exitCode, stderr, code, message) => { + const gh = fakeGh(() => output(body, exitCode, stderr)) + const subject = provider(gh.run) + expect([...(await subject.fetch([pull(1)], signal)).values()]).toEqual([{ code, message }]) + await expect(subject.findByHead(demo, 'topic', signal)).rejects.toThrow(`head branch lookup failed: ${message}`) + }) + it('rejects unconfigured hosts without querying them and still fetches allowed hosts', async () => { const gh = fakeGh(() => output({ data: { r0: { p0: pullRequestNode(1) } } })) const denied = pull(2, { ...enterprise, host: 'unconfigured.example' }) From 8d4c8865ddd2ef79f2ee77e5240227ca9717567e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 17:48:19 +0800 Subject: [PATCH 06/10] fix(forge-session): resync watches when providers change --- docs/event-producer-consumer.md | 3 +- docs/event-producer-consumer.zh.md | 3 +- docs/subsystems/forge.md | 26 ++++++++++++-- docs/subsystems/forge.zh.md | 26 ++++++++++++-- .../forge-session/README.i18n.yaml | 4 +-- packages/experimental/forge-session/README.md | 2 +- .../experimental/forge-session/README.zh.md | 2 +- .../experimental/forge-session/src/index.ts | 10 ++++-- .../experimental/forge-session/src/types.ts | 2 +- .../forge-session/tests/service.spec.ts | 34 ++++++++++++++++++- packages/experimental/forge/README.i18n.yaml | 4 +-- packages/experimental/forge/README.md | 2 +- packages/experimental/forge/README.zh.md | 2 +- packages/experimental/forge/src/index.ts | 24 +++++++++++++ .../forge/tests/lifecycle.spec.ts | 11 ++++++ .../extensions/tool-cordis/src/api-catalog.ts | 10 +++++- scripts/gen-cordis-catalog.ts | 1 + 17 files changed, 147 insertions(+), 19 deletions(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 66867456de..beb59250f2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -47,6 +47,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | +| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | @@ -67,7 +68,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b30f3e1ac4..5020470377 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -49,6 +49,7 @@ | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | +| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | @@ -69,7 +70,7 @@ | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md index 2c1d1db11e..8ab5ae52d8 100644 --- a/docs/subsystems/forge.md +++ b/docs/subsystems/forge.md @@ -216,7 +216,7 @@ interface SessionChangeRequest { ```ts type-equiv /** - * Receives the complete visible list after a binding change or a polling result. + * Receives the complete visible list after a binding or provider change, or a polling result. * @param changeRequests - every visible binding of the watched Session, in first-recorded order. */ type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void @@ -338,7 +338,7 @@ dismiss(session: Session, url: string): void * Watch a Session's visible change requests as an effect on the calling * fiber. Every visible change request that a provider recognizes is polled * through `ctx.forge` while the Session has a watcher. Listeners receive the - * complete list after each later binding change or polling result; read the + * complete list after each later binding or provider change, or polling result; read the * current list with {@link changeRequests}. Listeners must not throw; a * thrown error is logged and skipped. * @param session - Session to watch. @@ -351,4 +351,26 @@ watch(session: Session, listener: SessionChangeRequestsListener): () => void Types: [Session](session.md) Source: [`packages/experimental/forge-session/src/index.ts`](../../packages/experimental/forge-session/src/index.ts) + + + +### `forge/*` events + + + +#### `forge/providers-changed` — parallel + +Announces a provider registration or withdrawal after the registry changes. Listener failures are logged without preventing other listeners from running. + +```ts cordis-catalog +/** + * Announces a provider registration or withdrawal after the registry changes. + * Listener failures are logged without preventing other listeners from running. + * @param forge - family whose provider changed. + * @mode parallel + */ +'forge/providers-changed'(forge: string): void | Promise +``` + +Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental/forge/src/index.ts) diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md index d14d6a1f43..988bd358b1 100644 --- a/docs/subsystems/forge.zh.md +++ b/docs/subsystems/forge.zh.md @@ -216,7 +216,7 @@ interface SessionChangeRequest { ```ts type-equiv /** - * Receives the complete visible list after a binding change or a polling result. + * Receives the complete visible list after a binding or provider change, or a polling result. * @param changeRequests - every visible binding of the watched Session, in first-recorded order. */ type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void @@ -338,7 +338,7 @@ dismiss(session: Session, url: string): void * Watch a Session's visible change requests as an effect on the calling * fiber. Every visible change request that a provider recognizes is polled * through `ctx.forge` while the Session has a watcher. Listeners receive the - * complete list after each later binding change or polling result; read the + * complete list after each later binding or provider change, or polling result; read the * current list with {@link changeRequests}. Listeners must not throw; a * thrown error is logged and skipped. * @param session - Session to watch. @@ -351,4 +351,26 @@ watch(session: Session, listener: SessionChangeRequestsListener): () => void Types: [Session](session.zh.md) Source: [`packages/experimental/forge-session/src/index.ts`](../../packages/experimental/forge-session/src/index.ts) + + + +### `forge/*` events + + + +#### `forge/providers-changed` — parallel + +Announces a provider registration or withdrawal after the registry changes. Listener failures are logged without preventing other listeners from running. + +```ts cordis-catalog +/** + * Announces a provider registration or withdrawal after the registry changes. + * Listener failures are logged without preventing other listeners from running. + * @param forge - family whose provider changed. + * @mode parallel + */ +'forge/providers-changed'(forge: string): void | Promise +``` + +Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental/forge/src/index.ts) diff --git a/packages/experimental/forge-session/README.i18n.yaml b/packages/experimental/forge-session/README.i18n.yaml index 1dd3eaaf22..8eac73087a 100644 --- a/packages/experimental/forge-session/README.i18n.yaml +++ b/packages/experimental/forge-session/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge-session/use-this-package: - en: 022ab6a18364aa44 - zh: adc91aef3f4273e3 + en: 647d713c5c8e7900 + zh: 5405b587e6cc27e7 /deepseek-ai-dsh-experimental-forge-session/understand-the-implementation: en: ccc7ad69f5837528 zh: 369c08bf4f7e6e4d diff --git a/packages/experimental/forge-session/README.md b/packages/experimental/forge-session/README.md index a242d1b5a5..1d76ddd15c 100644 --- a/packages/experimental/forge-session/README.md +++ b/packages/experimental/forge-session/README.md @@ -34,7 +34,7 @@ Mount the service with the forge registry, a provider, and the Session projectio - name: '@deepseek-ai/dsh-experimental-forge-session' ``` -`ctx.forgeSession.changeRequests(session)` returns the visible bindings in first-recorded order. Each entry carries the URL, its `origin` (`created`, `discovered`, or `tracked`), the provider reference or `null` when no provider serves the URL, and the latest polling result or `null`. `watch(session, listener)` is an effect on the calling fiber: while a Session has a watcher, every visible binding that a provider serves is polled, and listeners receive the complete list after each later binding change or polling result. `track(session, url)` binds a URL under the provider's canonical form, or restores a dismissed binding, and throws when no provider recognizes the URL. `dismiss(session, url)` hides a visible binding; other URLs leave the log unchanged. +`ctx.forgeSession.changeRequests(session)` returns the visible bindings in first-recorded order. Each entry carries the URL, its `origin` (`created`, `discovered`, or `tracked`), the provider reference or `null` when no provider serves the URL, and the latest polling result or `null`. `watch(session, listener)` is an effect on the calling fiber: while a Session has a watcher, every visible binding that a provider serves is polled, and listeners receive the complete list after each later binding change, provider registration change, or polling result. Provider changes resynchronize existing watches, including URLs that were previously unserved. `track(session, url)` binds a URL under the provider's canonical form, or restores a dismissed binding, and throws when no provider recognizes the URL. `dismiss(session, url)` hides a visible binding; other URLs leave the log unchanged. A `bash` or `pwsh` call binds evidence only from its first result, and only when the tool execution itself succeeded; the command's exit status does not matter. A command containing `gh pr create` or `gh pr new` binds every `/pull/` URL in its text output. A command containing `git push`, optionally with leading `-C`, `-c`, or long Git options, records each branch listed under a `To ` line as created, updated, forced, or already up to date; rejected and deleted refs, new tags, and explicit refs outside `refs/heads/` are skipped. For each pushed branch whose remote a provider serves, the service runs `ctx.forge.findByHead` within `discoveryTimeoutMs` and appends a `discover` record for every returned change request that is not bound. Dismissed bindings remain bound and are skipped; only `track` restores them. Every push repeats the lookup; failures are logged as warnings. Closing a Session cancels its unfinished lookups without adding bindings. Reopening it does not repeat those lookups; another push or `track` can bind the change request. URLs that differ only in scheme, letter case, query, fragment, or trailing slashes name the same binding. diff --git a/packages/experimental/forge-session/README.zh.md b/packages/experimental/forge-session/README.zh.md index 076b876b21..92f01a744a 100644 --- a/packages/experimental/forge-session/README.zh.md +++ b/packages/experimental/forge-session/README.zh.md @@ -34,7 +34,7 @@ kind: "package-reference" - name: '@deepseek-ai/dsh-experimental-forge-session' ``` -`ctx.forgeSession.changeRequests(session)` 按首次记录的顺序返回可见的绑定。每一项包含 URL、其 `origin`(`created`、`discovered` 或 `tracked`)、提供方引用(没有提供方服务该 URL 时为 `null`)以及最近一次轮询结果(尚无结果时为 `null`)。`watch(session, listener)` 是调用方 fiber 上的 effect:Session 有监听方期间,提供方服务的每个可见绑定都会被轮询;此后每次绑定变化或轮询结果到达时,监听方都会收到完整列表。`track(session, url)` 以提供方的规范形式绑定 URL,或恢复已隐藏的绑定;没有提供方识别该 URL 时抛错。`dismiss(session, url)` 隐藏一个可见绑定;其他 URL 不会改变日志。 +`ctx.forgeSession.changeRequests(session)` 按首次记录的顺序返回可见的绑定。每一项包含 URL、其 `origin`(`created`、`discovered` 或 `tracked`)、提供方引用(没有提供方服务该 URL 时为 `null`)以及最近一次轮询结果(尚无结果时为 `null`)。`watch(session, listener)` 是调用方 fiber 上的 effect:Session 有监听方期间,提供方服务的每个可见绑定都会被轮询;此后每次绑定变化、提供方注册变化或轮询结果到达时,监听方都会收到完整列表。提供方变化会重新同步已有监听,包括此前没有提供方服务的 URL。`track(session, url)` 以提供方的规范形式绑定 URL,或恢复已隐藏的绑定;没有提供方识别该 URL 时抛错。`dismiss(session, url)` 隐藏一个可见绑定;其他 URL 不会改变日志。 `bash` 或 `pwsh` 调用只从其第一个结果中提取证据,且仅当工具执行本身成功时才提取;命令的退出状态不影响结果。包含 `gh pr create` 或 `gh pr new` 的命令会绑定其文本输出中的每个 `/pull/` URL。包含 `git push` 的命令(可带前置的 `-C`、`-c` 或 Git 长选项)会记录 `To ` 行下列出的每个已创建、已更新、已强制推送或已是最新的分支;被拒绝、已删除的引用、新标签,以及明确位于 `refs/heads/` 之外的引用会被跳过。对远端由提供方服务的每个被推送分支,服务在 `discoveryTimeoutMs` 内调用 `ctx.forge.findByHead`,并为返回的每个尚未绑定的变更请求追加一条 `discover` 记录。已隐藏的绑定仍视为已绑定而被跳过;只有 `track` 会恢复它们。每次推送都会重新查找;失败记录为警告日志。关闭 Session 会取消其未完成的查找,不追加绑定。重新打开不会补做这些查找;可再次推送或用 `track` 绑定变更请求。仅在协议、大小写、查询、片段或末尾斜杠上不同的 URL 指向同一个绑定。 diff --git a/packages/experimental/forge-session/src/index.ts b/packages/experimental/forge-session/src/index.ts index 09ada55ce7..e3a55f3006 100644 --- a/packages/experimental/forge-session/src/index.ts +++ b/packages/experimental/forge-session/src/index.ts @@ -45,7 +45,7 @@ export class ForgeSessionService extends Service { private readonly home: Context private readonly discoveryTimeoutMs: number - private readonly watches = new WeakMap() + private readonly watches = new Map() private readonly discoveries = new Set>() private readonly sessionLifetimes = new WeakMap() private readonly lifetime = new AbortController() @@ -106,7 +106,7 @@ export class ForgeSessionService extends Service { * Watch a Session's visible change requests as an effect on the calling * fiber. Every visible change request that a provider recognizes is polled * through `ctx.forge` while the Session has a watcher. Listeners receive the - * complete list after each later binding change or polling result; read the + * complete list after each later binding or provider change, or polling result; read the * current list with {@link changeRequests}. Listeners must not throw; a * thrown error is logged and skipped. * @param session - Session to watch. @@ -144,6 +144,12 @@ export class ForgeSessionService extends Service { this.sessionLifetimes.get(session)?.abort(new Error('forge-session: Session was disposed')) this.sessionLifetimes.delete(session) }) + this.home.on('forge/providers-changed', () => { + for (const watch of [...this.watches.values()]) { + this.sync(watch) + this.notify(watch) + } + }) } private state(session: Session): ForgeChangeRequestsState { diff --git a/packages/experimental/forge-session/src/types.ts b/packages/experimental/forge-session/src/types.ts index b9c6083cb9..921bb525a5 100644 --- a/packages/experimental/forge-session/src/types.ts +++ b/packages/experimental/forge-session/src/types.ts @@ -73,7 +73,7 @@ export interface SessionChangeRequest { } /** - * Receives the complete visible list after a binding change or a polling result. + * Receives the complete visible list after a binding or provider change, or a polling result. * @param changeRequests - every visible binding of the watched Session, in first-recorded order. */ export type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void diff --git a/packages/experimental/forge-session/tests/service.spec.ts b/packages/experimental/forge-session/tests/service.spec.ts index 10f22b0091..df8796cce6 100644 --- a/packages/experimental/forge-session/tests/service.spec.ts +++ b/packages/experimental/forge-session/tests/service.spec.ts @@ -7,7 +7,7 @@ import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import ForgeSessionService from '../src/index.ts' import type { Config, SessionChangeRequest, SessionChangeRequestsListener } from '../src/index.ts' -import { bench, demo, pull, pullUrl, pushOutput, shellTurn, snapshot } from './fixtures.ts' +import { bench, demo, FakeProvider, pull, pullUrl, pushOutput, shellTurn, snapshot } from './fixtures.ts' import type { Bench } from './fixtures.ts' const contexts: Context[] = [] @@ -233,6 +233,38 @@ describe('forgeSession track and dismiss', () => { }) describe('forgeSession watch', () => { + it('resynchronizes existing watches when providers arrive, leave, and return', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(ForgeService) + await ctx.plugin(ForgeSessionService) + const session = ctx.sessions.create() + create(session, 'create', pullUrl(7)) + const listener = vi.fn() + const stop = ctx.forgeSession.watch(session, listener) + expect(ctx.forgeSession.changeRequests(session)[0]?.ref).toBeNull() + + const provider = new FakeProvider() + const register = () => ctx.plugin({ inject: ['forge'], apply(child: Context) { child.forge.register(provider) } }) + const first = register() + await first + await vi.waitFor(() => { expect(lastList(listener)[0]?.update).toMatchObject({ snapshot: { ref: pull(7) } }) }) + expect(provider.fetches).toEqual([[7]]) + await first.dispose() + expect(lastList(listener)[0]).toMatchObject({ ref: null, update: null }) + + await register() + await vi.waitFor(() => { expect(provider.fetches).toEqual([[7], [7]]) }) + expect(lastList(listener)[0]?.ref).toEqual(pull(7)) + stop() + const calls = listener.mock.calls.length + ctx.emit('forge/providers-changed', 'fake') + expect(listener).toHaveBeenCalledTimes(calls) + expect(ctx.forge.latest(pull(7))).toBeUndefined() + }) + function lastList(listener: Mock): readonly SessionChangeRequest[] { return listener.mock.lastCall?.[0] ?? [] } diff --git a/packages/experimental/forge/README.i18n.yaml b/packages/experimental/forge/README.i18n.yaml index ed4391fd31..e5563d1e71 100644 --- a/packages/experimental/forge/README.i18n.yaml +++ b/packages/experimental/forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge/use-this-package: - en: 341eeed1ab1b3e82 - zh: 0b97ec8bb557a6c9 + en: a16d7eb7b6abee1f + zh: e2b93e1aa95e2aaa /deepseek-ai-dsh-experimental-forge/understand-the-implementation: en: e193cb547c47069b zh: faa517522402ea41 diff --git a/packages/experimental/forge/README.md b/packages/experimental/forge/README.md index b8388c9371..7c71e843eb 100644 --- a/packages/experimental/forge/README.md +++ b/packages/experimental/forge/README.md @@ -32,7 +32,7 @@ Mount the registry with at least one provider package, such as the GitHub provid - name: '@deepseek-ai/dsh-experimental-forge-github' ``` -`ctx.forge.register(provider)` is an effect on the calling fiber and returns an async disposer that cancels and drains that registration's calls. Results arriving after cancellation are discarded. One provider serves one forge family; registering a second provider for the same family throws. `repositoryFromRemote(url)` and `changeRequestFromUrl(url)` ask providers in registration order and return the first match. `changeRequestUrl(ref)` and `findByHead(repository, branch, signal)` delegate to the provider of the reference's forge and throw when none is registered. +`ctx.forge.register(provider)` is an effect on the calling fiber and returns an async disposer that cancels and drains that registration's calls. Results arriving after cancellation are discarded. One provider serves one forge family; registering a second provider for the same family throws. Registration changes announce `forge/providers-changed`; registering a provider polls its existing watches immediately. `repositoryFromRemote(url)` and `changeRequestFromUrl(url)` ask providers in registration order and return the first match. `changeRequestUrl(ref)` and `findByHead(repository, branch, signal)` delegate to the provider of the reference's forge and throw when none is registered. `ctx.forge.watch(ref, listener)` is also an effect. The first watcher of a change request polls it at once; `latest(ref)` returns the most recent result. Each forge has at most one fetch in flight, and that fetch includes every due change request of the forge. An open change request is polled every `activeIntervalMs` while checks are pending or mergeability is unknown, and every `idleIntervalMs` otherwise. Closed and merged change requests are not polled again. A failed read repeats after `activeIntervalMs` doubled for each consecutive failure, up to `maxBackoffMs`. Unregistering a provider settles its watched change requests as `unavailable`. Polling stops when the last watcher leaves. The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge) lists the defaults. diff --git a/packages/experimental/forge/README.zh.md b/packages/experimental/forge/README.zh.md index c6f915549c..c34d6b7f8f 100644 --- a/packages/experimental/forge/README.zh.md +++ b/packages/experimental/forge/README.zh.md @@ -32,7 +32,7 @@ kind: "package-reference" - name: '@deepseek-ai/dsh-experimental-forge-github' ``` -`ctx.forge.register(provider)` 是调用方 fiber 上的 effect,返回异步 disposer,取消并等待该次注册的调用结束。取消后到达的结果会被丢弃。一个提供方服务一个 forge 家族;为同一家族注册第二个提供方会抛错。`repositoryFromRemote(url)` 与 `changeRequestFromUrl(url)` 按注册顺序询问提供方并返回第一个匹配结果。`changeRequestUrl(ref)` 与 `findByHead(repository, branch, signal)` 委托给引用所属 forge 的提供方;没有注册提供方时抛错。 +`ctx.forge.register(provider)` 是调用方 fiber 上的 effect,返回异步 disposer,取消并等待该次注册的调用结束。取消后到达的结果会被丢弃。一个提供方服务一个 forge 家族;为同一家族注册第二个提供方会抛错。注册变化会发出 `forge/providers-changed`;注册提供方会立即轮询其已有监听。`repositoryFromRemote(url)` 与 `changeRequestFromUrl(url)` 按注册顺序询问提供方并返回第一个匹配结果。`changeRequestUrl(ref)` 与 `findByHead(repository, branch, signal)` 委托给引用所属 forge 的提供方;没有注册提供方时抛错。 `ctx.forge.watch(ref, listener)` 同样是 effect。变更请求的第一个监听方会立即触发轮询;`latest(ref)` 返回最近一次结果。每个 forge 同时最多只有一次读取在进行,这次读取包含该 forge 所有到期的变更请求。打开状态的变更请求在检查未完成或可合并性未知时每 `activeIntervalMs` 轮询一次,否则每 `idleIntervalMs` 轮询一次。已关闭和已合并的变更请求不再轮询。读取失败后,重试间隔为 `activeIntervalMs` 按连续失败次数翻倍,最长 `maxBackoffMs`。注销提供方会让其被监听的变更请求以 `unavailable` 结束。最后一个监听方离开后轮询停止。默认值见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge)。 diff --git a/packages/experimental/forge/src/index.ts b/packages/experimental/forge/src/index.ts index 2096795a3e..59342af71c 100644 --- a/packages/experimental/forge/src/index.ts +++ b/packages/experimental/forge/src/index.ts @@ -17,6 +17,15 @@ declare module '@deepseek-ai/cordis' { interface Context { forge: ForgeService } + interface Events { + /** + * Announces a provider registration or withdrawal after the registry changes. + * Listener failures are logged without preventing other listeners from running. + * @param forge - family whose provider changed. + * @mode parallel + */ + 'forge/providers-changed'(forge: string): void | Promise + } } type FetchResults = ReadonlyMap @@ -99,12 +108,18 @@ export class ForgeService extends Service { if (this.providers.has(provider.forge)) throw new Error(`forge: a provider for "${provider.forge}" is already registered`) const registration: Registration = { provider, lifetime: new AbortController(), pending: new Set() } this.providers.set(provider.forge, registration) + for (const watch of this.watches.values()) { + if (watch.ref.repository.forge === provider.forge) watch.dueAt = Date.now() + } + this.providersChanged(provider.forge) + this.schedule() return async () => { this.providers.delete(provider.forge) registration.lifetime.abort(new Error(`forge: provider "${provider.forge}" was unregistered`)) for (const watch of [...this.watches.values()]) { if (watch.ref.repository.forge === provider.forge) this.settle(watch, this.missing(provider.forge)) } + this.providersChanged(provider.forge) this.schedule() await Promise.allSettled(registration.pending) } @@ -205,6 +220,15 @@ export class ForgeService extends Service { return this.registration(forge).provider } + private providersChanged(forge: string): void { + if (this.lifetime.signal.aborted) return + const task = this.ctx.parallel('forge/providers-changed', forge).catch((error: unknown) => { + this.ctx.logger.warn(`forge: provider change listener failed: ${String(error)}`) + }) + this.pending.add(task) + void task.then(() => this.pending.delete(task)) + } + private registration(forge: string): Registration { const registration = this.providers.get(forge) if (registration === undefined) throw new Error(`forge: ${this.missing(forge).message}`) diff --git a/packages/experimental/forge/tests/lifecycle.spec.ts b/packages/experimental/forge/tests/lifecycle.spec.ts index c2c67f2a68..ee68356eb9 100644 --- a/packages/experimental/forge/tests/lifecycle.spec.ts +++ b/packages/experimental/forge/tests/lifecycle.spec.ts @@ -80,3 +80,14 @@ it('does not invoke a provider for an already cancelled lookup', async () => { await expect(ctx.forge.findByHead(demo, 'topic', AbortSignal.abort(new Error('caller stopped')))).rejects.toThrow('caller stopped') expect(lookup).not.toHaveBeenCalled() }) + +it('contains failed registry observers and still notifies other observers', async () => { + ctx = await forgeContext() + const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const notified = Promise.withResolvers() + ctx.on('forge/providers-changed', () => { throw new Error('observer failed') }) + ctx.on('forge/providers-changed', (forge) => { notified.resolve(forge) }) + await provide(ctx, new FakeProvider()) + expect(await notified.promise).toBe('fake') + await vi.waitFor(() => { expect(warning).toHaveBeenCalledWith(expect.stringContaining('provider change listener failed')) }) +}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index bbe7cd9ac3..957ef8da8f 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1294,7 +1294,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'watch(session: Session, listener: SessionChangeRequestsListener): () => void', - description: 'Watch a Session\'s visible change requests as an effect on the calling fiber. Every visible change request that a provider recognizes is polled through `ctx.forge` while the Session has a watcher. Listeners receive the complete list after each later binding change or polling result; read the current list with changeRequests. Listeners must not throw; a thrown error is logged and skipped.', + description: 'Watch a Session\'s visible change requests as an effect on the calling fiber. Every visible change request that a provider recognizes is polled through `ctx.forge` while the Session has a watcher. Listeners receive the complete list after each later binding or provider change, or polling result; read the current list with changeRequests. Listeners must not throw; a thrown error is logged and skipped.', parameters: [{ name: 'session', description: 'Session to watch.' }, { name: 'listener', description: 'receives the complete visible list.' }], returns: 'the disposer that stops this watch.', }, @@ -4638,6 +4638,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'Observe a durable cold feedback mutation without publishing a live Session. Observers run before write ownership is released and must not await another message-feedback operation for this Session. The payload is borrowed read-only; deep-clone it before transferring ownership (for example, to Session.fromRestore).', parameters: [{ name: 'inspection', description: 'committed canonical prefix, including the feedback as its last event.' }], }, + { + name: 'forge/providers-changed', + mode: 'parallel', + signature: '\'forge/providers-changed\'(forge: string): void | Promise', + summary: 'Announces a provider registration or withdrawal after the registry changes.', + description: 'Announces a provider registration or withdrawal after the registry changes. Listener failures are logged without preventing other listeners from running.', + parameters: [{ name: 'forge', description: 'family whose provider changed.' }], + }, { name: 'fs/edit-intent', mode: 'waterfall', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 081026d7b5..75d28a915a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -243,6 +243,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'credentials': 'credentials.md', 'deepseek-account': 'credentials.md', 'domain': 'storage.md', + 'forge': 'forge.md', 'fs': 'filesystem.md', 'goal': 'goal.md', 'schedule': 'schedule.md', From 1ebb31915bbc9540b008378caf127798057423a8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 17:49:24 +0800 Subject: [PATCH 07/10] fix(forge-github): preserve HTTP remote ports --- packages/experimental/forge-github/README.i18n.yaml | 4 ++-- packages/experimental/forge-github/README.md | 2 +- packages/experimental/forge-github/README.zh.md | 2 +- packages/experimental/forge-github/src/urls.ts | 6 ++++-- .../experimental/forge-github/tests/provider.spec.ts | 10 ++++++++++ packages/experimental/forge-github/tests/urls.spec.ts | 7 +++++++ 6 files changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/experimental/forge-github/README.i18n.yaml b/packages/experimental/forge-github/README.i18n.yaml index a38adba694..5562ab4c54 100644 --- a/packages/experimental/forge-github/README.i18n.yaml +++ b/packages/experimental/forge-github/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge-github/use-this-package: - en: 3ce1c5d33f0abb6d - zh: 3ff3b4d8f3ed860f + en: 5bb5561b23d85467 + zh: 4d65d9c776983419 /deepseek-ai-dsh-experimental-forge-github/understand-the-implementation: en: 194951c8b5093f7d zh: 9c5ffaa9e531bd1f diff --git a/packages/experimental/forge-github/README.md b/packages/experimental/forge-github/README.md index 2aa4aa1e11..b39f390815 100644 --- a/packages/experimental/forge-github/README.md +++ b/packages/experimental/forge-github/README.md @@ -36,7 +36,7 @@ Mount the provider after the forge registry and list every GitHub Enterprise hos The provider runs `gh` as a host process, even when Sessions execute commands in another execution world. Sign in once per host with `gh auth login --hostname `. The child receives the host environment without `DSH_*` variables and without variables whose names contain `KEY`, `PASSWORD`, `SECRET`, or `TOKEN`, so `GH_TOKEN` and `GITHUB_TOKEN` are not forwarded and `gh` uses its own credential store. Prompts, update notices, pagers, and colors are disabled. `ghCommand`, `hosts`, `timeoutMs`, and `maxOutputBytes` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge-github). -The provider serves a remote URL in `https`, `http`, `ssh`, or `git` form, or in scp form such as `git@github.com:owner/name.git`, when its host is configured. It serves pull request URLs of the form `https://///pull/` with any trailing path, query, or fragment, and builds that form without the suffix as the canonical URL. A head lookup returns up to 20 open pull requests whose head is the branch, newest first, excluding pull requests from forks; a lookup of the default branch returns none. +The provider serves a remote URL in `https`, `http`, `ssh`, or `git` form, or in scp form such as `git@github.com:owner/name.git`, when its host is configured. HTTP(S) remotes retain custom ports in the host identity; SSH and Git transport ports are excluded. It serves pull request URLs of the form `https://///pull/` with any trailing path, query, or fragment, and builds that form without the suffix as the canonical URL. A head lookup returns up to 20 open pull requests whose head is the branch, newest first, excluding pull requests from forks; a lookup of the default branch returns none. Check runs map to `pending` until completed, then to `success`, `failure` (failure, timed out, startup failure, or action required), `cancelled`, `skipped`, or `neutral`. Commit status contexts map `SUCCESS` to `success`, `FAILURE` and `ERROR` to `failure`, and every other state to `pending`. A GraphQL `NOT_FOUND` error becomes a `not-found` failure. A response without GraphQL data or errors, including an HTTP error JSON body, uses the first stderr diagnostic or the body's message. Authentication diagnostics become `unauthenticated`; rate limits and other failures become `unavailable`. Head lookups preserve the same diagnostic in their rejection. diff --git a/packages/experimental/forge-github/README.zh.md b/packages/experimental/forge-github/README.zh.md index 821b468f72..9b090019cb 100644 --- a/packages/experimental/forge-github/README.zh.md +++ b/packages/experimental/forge-github/README.zh.md @@ -36,7 +36,7 @@ kind: "package-reference" 即使 Session 在其他执行环境中运行命令,提供方也以宿主进程的形式运行 `gh`。每个主机执行一次 `gh auth login --hostname ` 完成登录。子进程继承的宿主环境去掉了 `DSH_*` 变量以及名称包含 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN` 的变量,因此 `GH_TOKEN` 与 `GITHUB_TOKEN` 不会被转发,`gh` 使用自己的凭据存储。交互提示、更新提醒、分页器与颜色输出均被关闭。`ghCommand`、`hosts`、`timeoutMs` 与 `maxOutputBytes` 见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge-github)。 -当主机在配置中时,提供方服务 `https`、`http`、`ssh`、`git` 形式的远端 URL,以及 `git@github.com:owner/name.git` 这类 scp 形式。它服务 `https://///pull/` 形式的 pull request URL,允许其后附带任意路径、查询或片段,并以去掉这些后缀的形式作为规范 URL。按 head 分支查找时,最多返回 20 个以该分支为 head 的打开状态 pull request,按创建时间从新到旧排列,不包含来自 fork 的 pull request;查找默认分支时返回空结果。 +当主机在配置中时,提供方服务 `https`、`http`、`ssh`、`git` 形式的远端 URL,以及 `git@github.com:owner/name.git` 这类 scp 形式。HTTP(S) 远端在主机标识中保留自定义端口;SSH 和 Git 传输端口不计入主机标识。它服务 `https://///pull/` 形式的 pull request URL,允许其后附带任意路径、查询或片段,并以去掉这些后缀的形式作为规范 URL。按 head 分支查找时,最多返回 20 个以该分支为 head 的打开状态 pull request,按创建时间从新到旧排列,不包含来自 fork 的 pull request;查找默认分支时返回空结果。 检查运行(check run)在完成前映射为 `pending`,完成后映射为 `success`、`failure`(失败、超时、启动失败或需要操作)、`cancelled`、`skipped` 或 `neutral`。提交状态上下文把 `SUCCESS` 映射为 `success`,把 `FAILURE` 与 `ERROR` 映射为 `failure`,其余状态均映射为 `pending`。GraphQL 的 `NOT_FOUND` 错误转为 `not-found` 失败。响应不含 GraphQL data 或 errors 时(包括 HTTP 错误 JSON),使用 stderr 的首条诊断或响应体的 message。认证诊断转为 `unauthenticated`;限流与其他失败转为 `unavailable`。head 查找在拒绝时保留相同诊断。 diff --git a/packages/experimental/forge-github/src/urls.ts b/packages/experimental/forge-github/src/urls.ts index 8349f9b591..64b42988d1 100644 --- a/packages/experimental/forge-github/src/urls.ts +++ b/packages/experimental/forge-github/src/urls.ts @@ -26,13 +26,15 @@ export function isSegment(value: string): boolean { * Parse a Git remote URL. Accepts `http(s)://[user@]host[:port]/owner/name`, * `ssh://[user@]host[:port]/owner/name`, `git://host/owner/name`, and the * scp-like `[user@]host:owner/name`, each with an optional `.git` suffix or - * trailing slash. + * trailing slash. HTTP(S) ports belong to the host identity; SSH and Git + * transport ports do not. * @param url - fetch or push URL as Git reports it. * @returns the location, or undefined for other URL shapes. */ export function parseRemoteUrl(url: string): GitHubLocation | undefined { const trimmed = url.trim() - const scheme = /^(?:https?|ssh|git):\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(trimmed) + const scheme = /^https?:\/\/(?:[^@/]+@)?([^/:]+(?::\d+)?)\/(.+)$/i.exec(trimmed) + ?? /^(?:ssh|git):\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(trimmed) const scp = scheme === null ? /^(?:[^@/:]+@)?([^/:]+):(?!\/\/)(.+)$/.exec(trimmed) : null const match = scheme ?? scp if (match === null) return undefined diff --git a/packages/experimental/forge-github/tests/provider.spec.ts b/packages/experimental/forge-github/tests/provider.spec.ts index d80b8203f3..da0df8fba4 100644 --- a/packages/experimental/forge-github/tests/provider.spec.ts +++ b/packages/experimental/forge-github/tests/provider.spec.ts @@ -42,6 +42,16 @@ describe('GitHub provider addressing', () => { it('builds canonical pull request URLs', () => { expect(subject.changeRequestUrl(pull(4, enterprise))).toBe('https://ghe.corp.example/team/svc/pull/4') }) + + it('uses a configured HTTP port for URL mapping and GitHub queries', async () => { + const gh = fakeGh(() => output({ data: { r0: { p0: pullRequestNode(1) } } })) + const subject = new GitHubProvider(gh.run, ['ghe.example.com:8443']) + const repository = subject.repositoryFromRemote('https://ghe.example.com:8443/team/repo.git')! + const ref = { repository, number: 1 } + expect(subject.changeRequestFromUrl(subject.changeRequestUrl(ref))).toEqual(ref) + await subject.fetch([ref], signal) + expect(gh.calls[0]?.[3]).toBe('ghe.example.com:8443') + }) }) describe('GitHub provider fetch', () => { diff --git a/packages/experimental/forge-github/tests/urls.spec.ts b/packages/experimental/forge-github/tests/urls.spec.ts index e202507e7a..b8299f7fef 100644 --- a/packages/experimental/forge-github/tests/urls.spec.ts +++ b/packages/experimental/forge-github/tests/urls.spec.ts @@ -9,6 +9,9 @@ describe('remote URLs', () => { ['https://user:secret@GitHub.com/octo/demo', demo], ['http://github.com/octo/demo/', demo], ['ssh://git@github.com:22/octo/demo.git', demo], + ['https://ghe.example.com:8443/team/repo.git', { host: 'ghe.example.com:8443', owner: 'team', name: 'repo' }], + ['http://ghe.example.com:8080/team/repo', { host: 'ghe.example.com:8080', owner: 'team', name: 'repo' }], + ['SSH://git@ghe.example.com:7999/team/repo.git', { host: 'ghe.example.com', owner: 'team', name: 'repo' }], ['git://github.com/octo/demo', demo], ['git@github.com:octo/demo.git', demo], ['github.com:octo/demo.git', demo], @@ -40,6 +43,10 @@ describe('remote URLs', () => { }) describe('pull request URLs', () => { + it('uses the same host identity as an HTTP remote with a custom port', () => { + const remote = parseRemoteUrl('https://ghe.example.com:8443/team/repo.git') + expect(parsePullRequestUrl('https://ghe.example.com:8443/team/repo/pull/1')).toEqual({ ...remote, number: 1 }) + }) it.each([ ['https://github.com/octo/demo/pull/12', { ...demo, number: 12 }], ['https://GITHUB.com/octo/demo/pull/12/files?diff=split#top', { ...demo, number: 12 }], From 7457789b984141855b0b416ed6c89c2632b1d2d3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 17:49:26 +0800 Subject: [PATCH 08/10] fix(forge): reject backoff caps below the active interval --- packages/experimental/forge/README.i18n.yaml | 4 ++-- packages/experimental/forge/README.md | 2 +- packages/experimental/forge/README.zh.md | 2 +- packages/experimental/forge/src/index.ts | 3 +++ packages/experimental/forge/tests/registry.spec.ts | 12 ++++++++++++ 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/experimental/forge/README.i18n.yaml b/packages/experimental/forge/README.i18n.yaml index e5563d1e71..e76ca4bc14 100644 --- a/packages/experimental/forge/README.i18n.yaml +++ b/packages/experimental/forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge/use-this-package: - en: a16d7eb7b6abee1f - zh: e2b93e1aa95e2aaa + en: 83fdebf81ffa8010 + zh: fdda2e83a7b60072 /deepseek-ai-dsh-experimental-forge/understand-the-implementation: en: e193cb547c47069b zh: faa517522402ea41 diff --git a/packages/experimental/forge/README.md b/packages/experimental/forge/README.md index 7c71e843eb..af7da98e3f 100644 --- a/packages/experimental/forge/README.md +++ b/packages/experimental/forge/README.md @@ -34,7 +34,7 @@ Mount the registry with at least one provider package, such as the GitHub provid `ctx.forge.register(provider)` is an effect on the calling fiber and returns an async disposer that cancels and drains that registration's calls. Results arriving after cancellation are discarded. One provider serves one forge family; registering a second provider for the same family throws. Registration changes announce `forge/providers-changed`; registering a provider polls its existing watches immediately. `repositoryFromRemote(url)` and `changeRequestFromUrl(url)` ask providers in registration order and return the first match. `changeRequestUrl(ref)` and `findByHead(repository, branch, signal)` delegate to the provider of the reference's forge and throw when none is registered. -`ctx.forge.watch(ref, listener)` is also an effect. The first watcher of a change request polls it at once; `latest(ref)` returns the most recent result. Each forge has at most one fetch in flight, and that fetch includes every due change request of the forge. An open change request is polled every `activeIntervalMs` while checks are pending or mergeability is unknown, and every `idleIntervalMs` otherwise. Closed and merged change requests are not polled again. A failed read repeats after `activeIntervalMs` doubled for each consecutive failure, up to `maxBackoffMs`. Unregistering a provider settles its watched change requests as `unavailable`. Polling stops when the last watcher leaves. The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge) lists the defaults. +`ctx.forge.watch(ref, listener)` is also an effect. The first watcher of a change request polls it at once; `latest(ref)` returns the most recent result. Each forge has at most one fetch in flight, and that fetch includes every due change request of the forge. An open change request is polled every `activeIntervalMs` while checks are pending or mergeability is unknown, and every `idleIntervalMs` otherwise. Closed and merged change requests are not polled again. A failed read repeats after `activeIntervalMs` doubled for each consecutive failure, up to `maxBackoffMs`, which must be at least `activeIntervalMs`. Unregistering a provider settles its watched change requests as `unavailable`. Polling stops when the last watcher leaves. The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge) lists the defaults. Reaching `fetchTimeoutMs` aborts the fetch signal with `FORGE_FETCH_TIMEOUT`. Providers must stop their work and settle when the signal aborts; rejected fetches become `unavailable` failures. A provider that ignores cancellation can stall polling and service disposal. diff --git a/packages/experimental/forge/README.zh.md b/packages/experimental/forge/README.zh.md index c34d6b7f8f..b8beae8f31 100644 --- a/packages/experimental/forge/README.zh.md +++ b/packages/experimental/forge/README.zh.md @@ -34,7 +34,7 @@ kind: "package-reference" `ctx.forge.register(provider)` 是调用方 fiber 上的 effect,返回异步 disposer,取消并等待该次注册的调用结束。取消后到达的结果会被丢弃。一个提供方服务一个 forge 家族;为同一家族注册第二个提供方会抛错。注册变化会发出 `forge/providers-changed`;注册提供方会立即轮询其已有监听。`repositoryFromRemote(url)` 与 `changeRequestFromUrl(url)` 按注册顺序询问提供方并返回第一个匹配结果。`changeRequestUrl(ref)` 与 `findByHead(repository, branch, signal)` 委托给引用所属 forge 的提供方;没有注册提供方时抛错。 -`ctx.forge.watch(ref, listener)` 同样是 effect。变更请求的第一个监听方会立即触发轮询;`latest(ref)` 返回最近一次结果。每个 forge 同时最多只有一次读取在进行,这次读取包含该 forge 所有到期的变更请求。打开状态的变更请求在检查未完成或可合并性未知时每 `activeIntervalMs` 轮询一次,否则每 `idleIntervalMs` 轮询一次。已关闭和已合并的变更请求不再轮询。读取失败后,重试间隔为 `activeIntervalMs` 按连续失败次数翻倍,最长 `maxBackoffMs`。注销提供方会让其被监听的变更请求以 `unavailable` 结束。最后一个监听方离开后轮询停止。默认值见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge)。 +`ctx.forge.watch(ref, listener)` 同样是 effect。变更请求的第一个监听方会立即触发轮询;`latest(ref)` 返回最近一次结果。每个 forge 同时最多只有一次读取在进行,这次读取包含该 forge 所有到期的变更请求。打开状态的变更请求在检查未完成或可合并性未知时每 `activeIntervalMs` 轮询一次,否则每 `idleIntervalMs` 轮询一次。已关闭和已合并的变更请求不再轮询。读取失败后,重试间隔为 `activeIntervalMs` 按连续失败次数翻倍,最长 `maxBackoffMs`;该值必须大于或等于 `activeIntervalMs`。注销提供方会让其被监听的变更请求以 `unavailable` 结束。最后一个监听方离开后轮询停止。默认值见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge)。 达到 `fetchTimeoutMs` 时,读取信号会以 `FORGE_FETCH_TIMEOUT` 中止。提供方必须在信号中止时停止工作并结束请求;被拒绝的读取会转为 `unavailable` 失败。忽略取消的提供方可能阻塞轮询和服务销毁。 diff --git a/packages/experimental/forge/src/index.ts b/packages/experimental/forge/src/index.ts index 59342af71c..35b9267c1b 100644 --- a/packages/experimental/forge/src/index.ts +++ b/packages/experimental/forge/src/index.ts @@ -86,6 +86,9 @@ export class ForgeService extends Service { throw new Error(`forge: ${key} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`) } } + if (this.config.maxBackoffMs < this.config.activeIntervalMs) { + throw new Error('forge: maxBackoffMs must be greater than or equal to activeIntervalMs') + } ctx.effect(() => async () => { this.lifetime.abort(new Error('forge service disposed')) clearTimeout(this.timer) diff --git a/packages/experimental/forge/tests/registry.spec.ts b/packages/experimental/forge/tests/registry.spec.ts index 3b97fa0dea..2790d749d4 100644 --- a/packages/experimental/forge/tests/registry.spec.ts +++ b/packages/experimental/forge/tests/registry.spec.ts @@ -11,6 +11,18 @@ afterEach(async () => { }) describe('forge registry', () => { + it('rejects a backoff cap shorter than the active poll interval', async () => { + ctx = new Context() + await expect(ctx.plugin(ForgeService, { activeIntervalMs: 30_000, maxBackoffMs: 1_000 })) + .rejects.toThrow('maxBackoffMs must be greater than or equal to activeIntervalMs') + }) + + it.each([1_000, 2_000])('accepts backoff cap %s at or above the active interval', async (maxBackoffMs) => { + ctx = new Context() + await ctx.plugin(ForgeService, { activeIntervalMs: 1_000, maxBackoffMs }) + expect(ctx.forge).toBeDefined() + }) + it('resolves remotes and change request URLs through the first provider that serves them', async () => { ctx = await forgeContext() await provide(ctx, new FakeProvider('fake')) From 4bef9db77e5e33276d67a920737aa28f19508a3b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 17:49:52 +0800 Subject: [PATCH 09/10] fix(forge-github): validate byte budgets separately from timeouts --- packages/experimental/forge-github/README.i18n.yaml | 4 ++-- packages/experimental/forge-github/README.md | 2 +- packages/experimental/forge-github/README.zh.md | 2 +- packages/experimental/forge-github/src/index.ts | 10 +++++----- .../experimental/forge-github/tests/plugin.spec.ts | 12 +++++++++++- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/experimental/forge-github/README.i18n.yaml b/packages/experimental/forge-github/README.i18n.yaml index 5562ab4c54..bb6e596fda 100644 --- a/packages/experimental/forge-github/README.i18n.yaml +++ b/packages/experimental/forge-github/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-forge-github/use-this-package: - en: 5bb5561b23d85467 - zh: 4d65d9c776983419 + en: 5b88115278a30c60 + zh: 530d946a8accf71f /deepseek-ai-dsh-experimental-forge-github/understand-the-implementation: en: 194951c8b5093f7d zh: 9c5ffaa9e531bd1f diff --git a/packages/experimental/forge-github/README.md b/packages/experimental/forge-github/README.md index b39f390815..f995bc6702 100644 --- a/packages/experimental/forge-github/README.md +++ b/packages/experimental/forge-github/README.md @@ -34,7 +34,7 @@ Mount the provider after the forge registry and list every GitHub Enterprise hos hosts: [github.com, ghe.example.com] ``` -The provider runs `gh` as a host process, even when Sessions execute commands in another execution world. Sign in once per host with `gh auth login --hostname `. The child receives the host environment without `DSH_*` variables and without variables whose names contain `KEY`, `PASSWORD`, `SECRET`, or `TOKEN`, so `GH_TOKEN` and `GITHUB_TOKEN` are not forwarded and `gh` uses its own credential store. Prompts, update notices, pagers, and colors are disabled. `ghCommand`, `hosts`, `timeoutMs`, and `maxOutputBytes` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge-github). +The provider runs `gh` as a host process, even when Sessions execute commands in another execution world. Sign in once per host with `gh auth login --hostname `. The child receives the host environment without `DSH_*` variables and without variables whose names contain `KEY`, `PASSWORD`, `SECRET`, or `TOKEN`, so `GH_TOKEN` and `GITHUB_TOKEN` are not forwarded and `gh` uses its own credential store. Prompts, update notices, pagers, and colors are disabled. `maxOutputBytes` must be a positive safe integer; only `timeoutMs` is bounded by the runtime timer limit. `ghCommand`, `hosts`, `timeoutMs`, and `maxOutputBytes` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge-github). The provider serves a remote URL in `https`, `http`, `ssh`, or `git` form, or in scp form such as `git@github.com:owner/name.git`, when its host is configured. HTTP(S) remotes retain custom ports in the host identity; SSH and Git transport ports are excluded. It serves pull request URLs of the form `https://///pull/` with any trailing path, query, or fragment, and builds that form without the suffix as the canonical URL. A head lookup returns up to 20 open pull requests whose head is the branch, newest first, excluding pull requests from forks; a lookup of the default branch returns none. diff --git a/packages/experimental/forge-github/README.zh.md b/packages/experimental/forge-github/README.zh.md index 9b090019cb..904a73215e 100644 --- a/packages/experimental/forge-github/README.zh.md +++ b/packages/experimental/forge-github/README.zh.md @@ -34,7 +34,7 @@ kind: "package-reference" hosts: [github.com, ghe.example.com] ``` -即使 Session 在其他执行环境中运行命令,提供方也以宿主进程的形式运行 `gh`。每个主机执行一次 `gh auth login --hostname ` 完成登录。子进程继承的宿主环境去掉了 `DSH_*` 变量以及名称包含 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN` 的变量,因此 `GH_TOKEN` 与 `GITHUB_TOKEN` 不会被转发,`gh` 使用自己的凭据存储。交互提示、更新提醒、分页器与颜色输出均被关闭。`ghCommand`、`hosts`、`timeoutMs` 与 `maxOutputBytes` 见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge-github)。 +即使 Session 在其他执行环境中运行命令,提供方也以宿主进程的形式运行 `gh`。每个主机执行一次 `gh auth login --hostname ` 完成登录。子进程继承的宿主环境去掉了 `DSH_*` 变量以及名称包含 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN` 的变量,因此 `GH_TOKEN` 与 `GITHUB_TOKEN` 不会被转发,`gh` 使用自己的凭据存储。交互提示、更新提醒、分页器与颜色输出均被关闭。`maxOutputBytes` 必须是正安全整数;只有 `timeoutMs` 受运行时定时器上限约束。`ghCommand`、`hosts`、`timeoutMs` 与 `maxOutputBytes` 见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge-github)。 当主机在配置中时,提供方服务 `https`、`http`、`ssh`、`git` 形式的远端 URL,以及 `git@github.com:owner/name.git` 这类 scp 形式。HTTP(S) 远端在主机标识中保留自定义端口;SSH 和 Git 传输端口不计入主机标识。它服务 `https://///pull/` 形式的 pull request URL,允许其后附带任意路径、查询或片段,并以去掉这些后缀的形式作为规范 URL。按 head 分支查找时,最多返回 20 个以该分支为 head 的打开状态 pull request,按创建时间从新到旧排列,不包含来自 fork 的 pull request;查找默认分支时返回空结果。 diff --git a/packages/experimental/forge-github/src/index.ts b/packages/experimental/forge-github/src/index.ts index e13637a9a8..bdb3a8f2ea 100644 --- a/packages/experimental/forge-github/src/index.ts +++ b/packages/experimental/forge-github/src/index.ts @@ -38,11 +38,11 @@ export function apply(ctx: Context, config: Config): void { for (const host of resolved.hosts) { if (!HOST.test(host)) throw new Error(`forge-github: invalid host ${JSON.stringify(host)}`) } - for (const key of ['timeoutMs', 'maxOutputBytes'] as const) { - const value = resolved[key] - if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) { - throw new Error(`forge-github: ${key} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`) - } + if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0 || resolved.timeoutMs > MAX_TIMER_DELAY_MS) { + throw new Error(`forge-github: timeoutMs must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`) + } + if (!Number.isSafeInteger(resolved.maxOutputBytes) || resolved.maxOutputBytes <= 0) { + throw new Error('forge-github: maxOutputBytes must be a positive safe integer') } ctx.forge.register(new GitHubProvider(ghRunner(resolved), resolved.hosts)) } diff --git a/packages/experimental/forge-github/tests/plugin.spec.ts b/packages/experimental/forge-github/tests/plugin.spec.ts index d58436268d..a214182694 100644 --- a/packages/experimental/forge-github/tests/plugin.spec.ts +++ b/packages/experimental/forge-github/tests/plugin.spec.ts @@ -51,10 +51,20 @@ describe('forge-github plugin', () => { [{ hosts: [] }, 'forge-github: hosts must list at least one host'], [{ hosts: ['https://github.com'] }, 'forge-github: invalid host "https://github.com"'], [{ timeoutMs: 0 }, 'forge-github: timeoutMs must be a positive integer no greater than 2147483647'], - [{ maxOutputBytes: 1.5 }, 'forge-github: maxOutputBytes must be a positive integer no greater than 2147483647'], + [{ timeoutMs: 2_147_483_648 }, 'forge-github: timeoutMs must be a positive integer no greater than 2147483647'], + [{ timeoutMs: 1.5 }, 'forge-github: timeoutMs must be a positive integer no greater than 2147483647'], + [{ maxOutputBytes: 1.5 }, 'forge-github: maxOutputBytes must be a positive safe integer'], + [{ maxOutputBytes: 0 }, 'forge-github: maxOutputBytes must be a positive safe integer'], + [{ maxOutputBytes: Number.MAX_SAFE_INTEGER + 1 }, 'forge-github: maxOutputBytes must be a positive safe integer'], ])('rejects %j at load', async (config, message) => { const context = await forge() await expect(context.plugin(ForgeGitHub, config)).rejects.toThrow(message) }) + + it('does not apply timer limits to the output byte budget', async () => { + const context = await forge() + await context.plugin(ForgeGitHub, { maxOutputBytes: 2_147_483_648 }) + expect(context.forge.repositoryFromRemote('https://github.com/octo/demo.git')).toBeDefined() + }) }) From 7fa3deaf827600d9e7fd4b6c6fde419617f3440f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 17:59:54 +0800 Subject: [PATCH 10/10] fix(forge-session): preserve cancelled watches during registry updates --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../experimental/forge-session/src/index.ts | 3 +- .../forge-session/tests/service.spec.ts | 6 ++++ .../forge/tests/lifecycle.spec.ts | 32 +++++++++++++++++-- 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index fb652ece7c..5700332827 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1116,7 +1116,7 @@ export interface Config { ## `@deepseek-ai/dsh-experimental-forge-session` -- `inject`: `forge` · `sessionProjections` +- `inject`: `forge` · `sessions` · `sessionProjections` - `source`: [`packages/experimental/forge-session/src/types.ts:82`](../packages/experimental/forge-session/src/types.ts) ```ts config-catalog diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 5b92255253..3d5e5e9fd7 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1118,7 +1118,7 @@ export interface Config { ## `@deepseek-ai/dsh-experimental-forge-session` -- `inject`: `forge` · `sessionProjections` +- `inject`: `forge` · `sessions` · `sessionProjections` - `source`: [`packages/experimental/forge-session/src/types.ts:82`](../packages/experimental/forge-session/src/types.ts) ```ts config-catalog diff --git a/packages/experimental/forge-session/src/index.ts b/packages/experimental/forge-session/src/index.ts index e3a55f3006..0996c62bcc 100644 --- a/packages/experimental/forge-session/src/index.ts +++ b/packages/experimental/forge-session/src/index.ts @@ -145,7 +145,8 @@ export class ForgeSessionService extends Service { this.sessionLifetimes.delete(session) }) this.home.on('forge/providers-changed', () => { - for (const watch of [...this.watches.values()]) { + for (const [session, watch] of [...this.watches]) { + if (this.watches.get(session) !== watch) continue this.sync(watch) this.notify(watch) } diff --git a/packages/experimental/forge-session/tests/service.spec.ts b/packages/experimental/forge-session/tests/service.spec.ts index df8796cce6..4816580857 100644 --- a/packages/experimental/forge-session/tests/service.spec.ts +++ b/packages/experimental/forge-session/tests/service.spec.ts @@ -245,6 +245,12 @@ describe('forgeSession watch', () => { const listener = vi.fn() const stop = ctx.forgeSession.watch(session, listener) expect(ctx.forgeSession.changeRequests(session)[0]?.ref).toBeNull() + const other = ctx.sessions.create() + create(other, 'other', pullUrl(8)) + const stopOther = ctx.forgeSession.watch(other, () => {}) + listener.mockImplementation((entries) => { + if (entries[0]?.ref !== null) stopOther() + }) const provider = new FakeProvider() const register = () => ctx.plugin({ inject: ['forge'], apply(child: Context) { child.forge.register(provider) } }) diff --git a/packages/experimental/forge/tests/lifecycle.spec.ts b/packages/experimental/forge/tests/lifecycle.spec.ts index ee68356eb9..c07d2cb5d3 100644 --- a/packages/experimental/forge/tests/lifecycle.spec.ts +++ b/packages/experimental/forge/tests/lifecycle.spec.ts @@ -1,7 +1,7 @@ import { afterEach, expect, it, vi } from 'vitest' -import type { Context } from '@deepseek-ai/cordis' +import { Context } from '@deepseek-ai/cordis' import type { ChangeRequestUpdate, ForgeProvider } from '../src/types.ts' -import { changeRequestKey } from '../src/index.ts' +import ForgeService, { changeRequestKey } from '../src/index.ts' import { demo, FakeProvider, forgeContext, provide, ref, snapshot } from './fake-provider.ts' let ctx: Context | undefined @@ -91,3 +91,31 @@ it('contains failed registry observers and still notifies other observers', asyn expect(await notified.promise).toBe('fake') await vi.waitFor(() => { expect(warning).toHaveBeenCalledWith(expect.stringContaining('provider change listener failed')) }) }) + +it('immediately polls existing watches only for the provider being registered', async () => { + vi.useFakeTimers() + ctx = await forgeContext() + const updates: ChangeRequestUpdate[] = [] + ctx.forge.watch(ref(1), (update) => { updates.push(update) }) + ctx.forge.watch(ref(2, { ...demo, forge: 'other' }), () => {}) + await vi.advanceTimersByTimeAsync(0) + expect(updates.at(-1)).toMatchObject({ failure: { code: 'unavailable' } }) + const provider = new FakeProvider() + provider.results.set(changeRequestKey(ref(1)), snapshot(ref(1))) + await provide(ctx, provider) + await vi.advanceTimersByTimeAsync(0) + expect(provider.fetches).toEqual([[ref(1)]]) + expect(updates.at(-1)).toMatchObject({ snapshot: snapshot(ref(1)) }) +}) + +it('does not announce provider withdrawal after its registry has stopped', async () => { + ctx = new Context() + const service = ctx.plugin(ForgeService) + await service + const observer = vi.fn() + ctx.on('forge/providers-changed', observer) + const unregister = ctx.forge.register(new FakeProvider()) + await service.dispose() + await unregister() + expect(observer).toHaveBeenCalledTimes(1) +})