Skip to content

fix(service-automation): a connection marked reconnect required says the cause that actually happened (#28863) - #28955

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-xqgh8b
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-xqgh8b

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #28863

Clause-②: no
Metadata-protocol: none
Skills-docs: none — no file under skills/** teaches the connection's reconnect_required causes (0 hits for reconnect_required, reconnect required and refused the grant).

维护者速读

  • 改了什么:连接器连接变成「需要重新连接」有四种原因:授权服务器在刷新时拒绝授权;它没发 refresh token;连接没存可用来刷新的 client;MCP 工具发现时上游拒绝了令牌。存下的 status_reason、sys_connector_connection.status 字段说明、markReconnectRequired 注释和 connectors.mdx 以前都只说第一种。现在三处文字列出全部四种;「没有 refresh token」和「没有 client」两条路径存下的原因各自说真正发生的事,不再先声称服务器拒绝,也不再说访问令牌「已过期」(缓存未命中或调度 401 也会触发刷新,令牌可能仍有效)。
  • 为什么改:管理员读到的原因说服务器拒绝了授权,可服务器根本没被问过(spec: ConnectorConnectionState.status JSDoc says reconnect_required means only that the authorization server refused the grant; since PR #28598 a discovery refusal of the connection token sets it too #28614 开发者发现,docs(spec): reconnect_required docblock names every way the grant stops working #28862 合同审查补充)。
  • 风险与回滚:低。只改文字和两条路径存储的原因句;拒绝路径的句子一字不变;状态值、错误码、写入都不变。回滚即 revert。
  • 先看:packages/services/service-automation/src/oauth2-grant/grant-handle.ts 的 reconnectRequiredReason 与 refreshOnce;同目录 grant-handle.test.ts 末尾的 [#28863] 三条。
  • 两分钟验证:pnpm --filter @objectstack/service-automation exec vitest run src/oauth2-grant/grant-handle.test.ts,预期 Tests 22 passed (22)(main 上新加的 2 条红,对照两边都绿)。
  • 需要你做的:无,席位落地。spec 里同样的「expired」措辞(下面 Acceptance notes 点了行号)由席位转给 spec 车道。

What changes

  • reconnectRequiredReason(connector, organizationId, cause?) (oauth2-grant/grant-handle.ts) takes an optional ReconnectRequiredCause: 'refused' (the default, so every existing caller keeps the old sentence byte for byte), 'no_refresh_token' or 'no_client'. The sentence's cause clause is the one that happened:
    • refused: "its authorization server refused the grant (revoked, or expired with no usable refresh)" (unchanged);
    • no refresh token: "its authorization server issued no refresh token, so an access token the connection has to renew cannot be renewed";
    • no client: "its connection keeps no client to refresh with, so an access token it has to renew cannot be renewed".
  • refreshOnce passes each path's cause to grantRefused, so the stored status_reason and the dispatch's 503 message are that one sentence. Before, the two non-refusal paths stored the refusal sentence with the real cause appended, and the no-refresh-token one said "an expired access token".
  • Prose, all four causes, in PR docs(spec): reconnect_required docblock names every way the grant stops working #28862's words: the status field description in sys-connector-connection.object.ts (it lives in service-automation, not platform-objects as the order's path guessed), the ConnectorConnectionStore#markReconnectRequired docblock, and content/docs/automation/connectors.mdx (the paragraph at L689 and the boot-behaviour table row at L868). Each points at status_reason / statusReason for which cause happened.
  • Changeset .changeset/28863-reconnect-required-cause.md: patch on @objectstack/service-automation, both declaration lines. The docs page ships in no package.
  • No new error code, no new status value, no new gate, no packages/spec/**.

Why Clause-②: no. No accept set, status value, code or export changes meaning. What changes is prose and the reason text two refresh paths store and answer with (the 503 stays a 503 SERVICE_UNAVAILABLE).

Tests

Three new cases at the end of packages/services/service-automation/src/oauth2-grant/grant-handle.test.ts ([#28863]), on the file's in-memory connection store, each reading the row's stored statusReason and the dispatch refusal:

  • No refresh token: the reason names the missing refresh token and "an access token the connection has to renew", contains neither refused nor expired, equals the 503 message, and no token request is made.
  • No client (an instance that declares no client and a connection that keeps none): the reason names the missing client, contains neither refused nor expired, equals the 503 message, and no token request is made.
  • CONTROL, refused (invalid_grant): the reason still names the refusal, carries no token, and equals the 503 message.
Red on base, green on head
  • Base (grant-handle.ts from 5e1b9b0a, the new tests at head): Tests 2 failed | 20 passed (22). The two reds: expected 'connector instance 'crm' needs to b…' to contain 'issued no refresh token, so an access…' and the same for 'keeps no client to refresh with, so a…'. The refusal control is green both ways. Restored with git checkout HEAD -- grant-handle.ts, git diff HEAD empty.
  • Head: Tests 22 passed (22).

Acceptance notes

  • The spec half of item 5 is not in this PR (ruling 4). packages/spec/src/contracts/automation-service.ts L1145–1146, the ConnectorConnectionState.status JSDoc from PR docs(spec): reconnect_required docblock names every way the grant stops working #28862: "the connection keeps nothing to renew an / expired access token with". "expired" there is narrower than the code, for the same reason as above. The runtime wording here, "an access token it has to renew", is the proposed replacement, so the two can agree.
  • Who matched the old text. grant-handle.test.ts L322 matches issued no refresh token, which the new sentence keeps, so it stays green. connector-connections.test.ts L148–157 passes its own reason string to the store. connector-connection-door.test.ts L576 matches the plugin's onGrantRefused log line ("…refused the grant of organization…"), which this PR does not touch. No other consumer reads the reason text (searched objectstack, objectui and hotcrm for no usable refresh, refused the grant, expired access token and issued no refresh token).
  • Left as they are, the same class (noted, not filed):
    • the plugin's onGrantRefused warn line (plugin.ts ≈L3697) says "the authorization server refused the grant" on all three refresh paths;
    • the dispatch refusal for a row ALREADY marked (select in grant-handle.ts ≈L238, refreshOnce's first check, and the plugin's hold-reason fallback ≈L3189) uses the default refused sentence whatever cause the row stores. Answering with the row's own statusReason would make those honest too.

Verification

All readings at head 7cf0294a (the change 790e473c, merged once with origin/main, a clean merge). Full pnpm turbo run build --concurrency=2: 118/118.

Package suites, typecheck, lint
  • pnpm --filter @objectstack/service-automation test: Test Files 255 passed (255), Tests 3284 passed (3284), lock VERDICT command-exit 0. Its typecheck (tsc + test-typecheck): exit 0.
  • pnpm --filter @objectstack/platform-objects test: Test Files 108 passed (108), Tests 1503 passed (1503), command-exit 0. Its typecheck: exit 0. (No file of it changed; run as the order asks.)
  • The repo's pnpm lint command scoped to packages/services/service-automation: exit 0.
Derived gates (`dispatch-gates.mjs --commands`, 89 families)
  • All 89 exit 0 at 7cf0294a, the docs families included (check-affected-docs, check-drift-comment, check:docs-*, check:corpus-claim-drift); dispatch-gates --ran: 89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN. Record: pm-orders/server/28863-gates-ran.txt.
  • check:type-check-debt: OK, --re-measure: OK.
  • Repo-wide pnpm lint and the CI-only families are CI's; see the PR's check-runs.

Dogfood battery: not owed (no !, no narrowing arm) and not run.


Generated by Claude Code

…the cause that actually happened

reconnect_required is set four ways: the authorization server refuses the
grant on refresh, it issued no refresh token, the connection keeps no client
to refresh with, or the upstream refuses the token at MCP tool discovery.
The stored reason on the no-refresh-token and no-client paths opened with a
refusal no server made, and said the access token had expired, which a
refresh on a cache miss or a 401 does not imply.

reconnectRequiredReason takes the cause (refused by default, its sentence
unchanged), and each refresh path passes its own. The connection's status
field help, the markReconnectRequired docblock and connectors.mdx name all
four causes, in the spec contract's words.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

10 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ff88105061012c8b14f7ec937d7b3113e404e922 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4030a6c8c4ea36c0793dfc843bee0bd59bb7615a — the merge of head 7cf0294a870b814629328c120c3f3b2a37fb5a40 into base ff88105061012c8b14f7ec937d7b3113e404e922, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4030a6c8c4ea36c0793dfc843bee0bd59bb7615a && git checkout 4030a6c8c4ea36c0793dfc843bee0bd59bb7615a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ff88105061012c8b14f7ec937d7b3113e404e922 7cf0294a870b814629328c120c3f3b2a37fb5a40 && git checkout -B drift-repro ff88105061012c8b14f7ec937d7b3113e404e922 && git merge --no-ff 7cf0294a870b814629328c120c3f3b2a37fb5a40

node scripts/docs-audit/affected-docs.mjs --json ff88105061012c8b14f7ec937d7b3113e404e922

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-11-02T04:59Z

Head 7cf0294a, report 6569427358 (on #28863), claim 6568687926. Clause-②: no, with no arm: no contract review is owed. The seat read the diff itself.

item reading
Shape Draft, base main, Fixes #28863. 6 files (+116 / −20): reconnectRequiredReason and its three call sites in refreshOnce (oauth2-grant/grant-handle.ts), the markReconnectRequired docblock, the sys_connector_connection.status description (it lives in service-automation, not platform-objects as the order said), connectors.mdx (the L689 paragraph and the L868 row), 3 tests and a service-automation patch changeset. Metadata-protocol: none. No packages/spec/**, no gate, no error code, no status value.
Rule reconnectRequiredReason takes an optional cause ('refused' by default, with its sentence unchanged; 'no_refresh_token'; 'no_client'). Each refresh path passes its own, so the stored status_reason and the 503 name what happened: the two paths that never ask the server claim no refusal, and none says "expired". The new ReconnectRequiredCause type and the parameter are not exported from the package root (the seat read src/index.ts), so nothing is widened.
Evidence Base: 2 of the 3 new cases red (the two non-refusal paths); head 22/22; the refusal control green both ways. service-automation 3284 and platform-objects 1503 tests, typecheck in full, scoped lint, and 89/89 derived gates (docs included), 0 NOT-MEASURED. No consumer in objectstack, objectui or hotcrm reads the reason text. CI on head is running; auto-merge waits for it.
Carried The spec JSDoc half of item 5 (automation-service.ts L1145–1146, "expired") → #28961 (domain:spec, p3). The dev's two same-class leftovers (the refusal of an already-marked row uses the default refused sentence, and the onGrantRefused log) → #28963 (domain:server, p3). Both filed by this seat.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, with every check green or an expected skip.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant