Skip to content

Commit fe85ff9

Browse files
committed
fix: retire stale skill route identities
1 parent d492326 commit fe85ff9

14 files changed

Lines changed: 477 additions & 65 deletions

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
> **说明**: 版本概览摘要。最新版本的详细变更见下方表格首行的 `changelogs/releases/vX.Y.Z.md` 链接;历史版本见对应详细变更文件
44
> **最后更新**: 2026-08-04
5-
> **当前未发布**: v1.16.2 已归档;F1~F14 的 Profile 目标、ContextRead/SkillRoute、索引证据与发布门禁修复仍是本地候选,详见 [`changelogs/unreleased.md`](./changelogs/unreleased.md)。
5+
> **当前未发布**: v1.16.2 已归档;F1~F14 的 Profile 目标、ContextRead/SkillRoute、不可执行旧路由退役、索引证据与发布门禁修复仍是本地候选,详见 [`changelogs/unreleased.md`](./changelogs/unreleased.md)。
66
77
---
88

‎changelogs/unreleased.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
66
## 当前未发布实现候选
77

8-
- **Profile 目标、ContextRead/SkillRoute 与发布真相闭环修复(F1~F14)**:定向 `init --profile` 只解析真实项目且 `--dry-run` 全程零写入;生命周期改为 session-first 主动协调,ContextRead observation、阶段义务与 status/execute 共用身份和前置条件;memory 索引区分 freshness/trust/coverage/repair,且 daily 会话通过 `sessionId + sessionBinding` 绑定后续正文写入,错绑和无目标均零写入失败,旧版无绑定会话保持只读并自动续接到新 allocation;report index 诚实降级为维护预览;Codex PASS 改用包内可复算真实宿主证据,相关组合负例接入 changed/full/package-release;`hooks-runtime` 的 298 次隔离子进程回放保留原测试强度并使用 240 秒专属预算,避免 120 秒边界将真实 PASS 误判为超时;F14 审计阻断将 website 已有 `brace-expansion` override 从 5.0.8 提升到官方修复版 5.0.9,不新增审计例外。当前为本地未发布候选,不代表 npm、GitHub、tag 或远端 CI 已更新。
8+
- **Profile 目标、ContextRead/SkillRoute 与发布真相闭环修复(F1~F14)**:定向 `init --profile` 只解析真实项目且 `--dry-run` 全程零写入;生命周期改为 session-first 主动协调,ContextRead observation、阶段义务与 status/execute 共用身份和前置条件;当已提交路由因 runtime/capability/turn identity 变化而不再可执行时,Stop 保留 `processComplete=false` 与 pending 审计事实并将旧过程义务显式退役,不再返回 `action-required` 却没有可执行动作;可信旧路由的业务回复仍单独强制,下一条真实用户消息自动建立新 turn;五宿主能识别协调器当前生成且带 Hook carrier 的续页提示,避免把内部回送误当成新任务,同时不吞掉用户手工粘贴的相同文本;memory 索引区分 freshness/trust/coverage/repair,且 daily 会话通过 `sessionId + sessionBinding` 绑定后续正文写入,错绑和无目标均零写入失败,旧版无绑定会话保持只读并自动续接到新 allocation;report index 诚实降级为维护预览;Codex PASS 改用包内可复算真实宿主证据,相关组合负例接入 changed/full/package-release;`hooks-runtime` 的 298 次隔离子进程回放保留原测试强度并使用 240 秒专属预算,避免 120 秒边界将真实 PASS 误判为超时;F14 审计阻断将 website 已有 `brace-expansion` override 从 5.0.8 提升到官方修复版 5.0.9,不新增审计例外。当前为本地未发布候选,不代表 npm、GitHub、tag 或远端 CI 已更新。
99
- **v1.16.2 已归档**:ContextRead 来源回执耐久化、空会话标识重放修复、README 常见任务教程与 CLI 只读帮助已进入 `changelogs/releases/v1.16.2.md`。
1010
- **v1.16.1 已归档**:长会话提示有界化、会话治理状态隔离、项目绑定稳定性与唯一短名解析修复已进入 `changelogs/releases/v1.16.1.md`。
1111
- **v1.16.0 已归档**:首次初始化、项目 Profile、用户语言与 workspace runtime-state 一致性已进入 `changelogs/releases/v1.16.0.md`。

‎hooks/_runtime/evidence/codex-skill-route-pass.v1.json‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,16 @@
55
"hostVariant": "codex-cli/exec-user-global-local-stdio",
66
"testedVersion": "codex-cli 0.145.0",
77
"protocol": "MCP 2024-11-05",
8-
"runtimeContractDigest": "d091d063e4b5fb562af7b5fd033846f6058a1e674fe3f219145c0e02dc32bc9d",
9-
"hostAdapterDigest": "c73bcd90196689cddc015dc88837c7a35d7f6f94731c04ce8be2e0b21e428733",
10-
"sourceEvidenceDigest": "7d170bec4864f464aba6e94ba7b0620c8c742ca09499ef75b25e6cf686fb96f4",
8+
"runtimeContractDigest": "755ee5ca9114b8fb0061ce03a42ef6d5161d4bc63b9a779ae4e7f293f813dfba",
9+
"hostAdapterDigest": "dfb2fce5f1d754dc6fff008d28439261bf1309f6a133f68c4e3bd8b69856e6ca",
10+
"sourceEvidenceDigest": "5ad9a1a75e547067de9c9a807dbb475179afc2b05b3c9cc89af8a688131e85d6",
1111
"sourceProbe": {
1212
"schemaVersion": "SkillRouteS15EvidenceV1",
13-
"probeRunId": "s15-codex-production-c4d2bec5-4f29-4503-b193-de16d0cfab9a",
13+
"probeRunId": "s15-codex-production-fa9fe67d-d430-47f8-a92b-0ac8ff0f22a2",
1414
"authorizationSource": "capability-pass",
1515
"contextSource": "host-hooks",
1616
"receiptStatus": "relevant-complete",
17-
"completedAt": "2026-08-04T09:08:46.348Z"
17+
"completedAt": "2026-08-04T11:30:27.209Z"
1818
},
1919
"probe": {
2020
"schemaVersion": "SkillRouteProbeSummaryV1",

‎hooks/_runtime/host-skill-route-capabilities.v1.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
"status": "PASS",
1414
"testedVersion": "codex-cli 0.145.0",
1515
"protocol": "MCP 2024-11-05",
16-
"runtimeContractDigest": "d091d063e4b5fb562af7b5fd033846f6058a1e674fe3f219145c0e02dc32bc9d",
17-
"hostAdapterDigest": "c73bcd90196689cddc015dc88837c7a35d7f6f94731c04ce8be2e0b21e428733",
18-
"evidenceDigest": "6f12610d2da17cbc708d0fbd03711f1fe3f3947911f8115dfd9cff46f66a7292",
16+
"runtimeContractDigest": "755ee5ca9114b8fb0061ce03a42ef6d5161d4bc63b9a779ae4e7f293f813dfba",
17+
"hostAdapterDigest": "dfb2fce5f1d754dc6fff008d28439261bf1309f6a133f68c4e3bd8b69856e6ca",
18+
"evidenceDigest": "f00fc64c08fcaa851c4c14490f50df860c57b4efd30293f13f0884366675af5f",
1919
"evidenceRef": "hooks/_runtime/evidence/codex-skill-route-pass.v1.json",
2020
"entrySurface": "codex exec --ephemeral",
2121
"bootstrapDelivery": "user-global hooks.json UserPromptSubmit",

‎hooks/_runtime/lifecycle-host-adapters.cjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ function normalizeGrokToolResult(value) {
294294
return output
295295
}
296296

297-
const PROGRESSIVE_SKILL_ROUTE_CONTINUATION_RE = /^(?:<hook_prompt\b[^>]*>)?\s*Progressive Skill route (?:is incomplete:|context is stale;|stages remain pending:)/i
297+
const PROGRESSIVE_SKILL_ROUTE_CONTINUATION_RE = /^(?:<hook_prompt\b[^>]*>)?\s*Progressive Skill route (?:is incomplete:|context is stale;|stages remain pending:|requires [^\r\n]* before unrelated work\.|made no durable progress after \d+ reconciliation attempts\.)/i
298298

299299
function progressiveSkillRouteContinuationMetadata (payload = {}) {
300300
const containers = [

‎hooks/_runtime/lifecycle-skill-route-coordinator.cjs‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,15 @@ function buildRouteStateFingerprint (routeStop = {}, input = {}) {
9494
generation: routeStop.nextCall?.generation ?? null,
9595
planDigest: String(routeStop.planDigest || routeStop.nextCall?.planDigest || ''),
9696
processComplete: routeStop.processComplete === true,
97+
retired: routeStop.retired === true,
98+
retirementReason: routeStop.retirementReason || null,
99+
completionDisposition: routeStop.completionDisposition || null,
97100
pendingStageIds: [...(routeStop.pendingStageIds || [])].sort(),
98101
errorCode: routeStop.errorCode || null,
99102
nextOp: routeStop.nextOp || null,
100103
nextCall: routeStop.nextCall || null,
101104
selectedBusinessSkillId: routeStop.selectedBusinessSkillId || null,
105+
mustReplyCore: routeStop.mustReplyCore || null,
102106
businessSatisfied: routeStop.businessSatisfied !== false
103107
})
104108
}
@@ -159,17 +163,24 @@ function isExpectedRouteAction (routeStop, payload, contextPost = null) {
159163
}
160164

161165
function buildNextActionEnvelope (routeStop, input = {}) {
166+
const required = input.required !== false
162167
return {
163168
schemaVersion: NEXT_ACTION_SCHEMA,
164169
devcodexCode: 'progressive-skill-route',
165170
hookRunId: String(input.hookRunId || ''),
166171
stateFingerprint: String(input.stateFingerprint || ''),
167172
trigger: String(input.trigger || ''),
168-
status: input.circuitOpen ? 'blocked-no-progress' : 'action-required',
173+
status: required
174+
? (input.circuitOpen ? 'blocked-no-progress' : 'action-required')
175+
: (routeStop?.retired === true ? 'retired' : 'complete'),
169176
circuitOpen: input.circuitOpen === true,
170177
noProgressCount: Number(input.noProgressCount || 0),
171178
processComplete: routeStop?.processComplete === true,
179+
retired: routeStop?.retired === true,
180+
retirementReason: routeStop?.retirementReason || null,
181+
completionDisposition: routeStop?.completionDisposition || null,
172182
businessSatisfied: routeStop?.businessSatisfied !== false,
183+
mustReplyCore: routeStop?.mustReplyCore || null,
173184
errorCode: routeStop?.errorCode || null,
174185
pendingStageIds: routeStop?.pendingStageIds || [],
175186
nextOp: routeStop?.nextOp || null,
@@ -186,7 +197,9 @@ function reconcileProgressiveSkillRoute (state, routeStop, input = {}) {
186197
sessionKey: input.sessionKey || ''
187198
})
188199
const requiresBusiness = input.requireBusiness === true
189-
const processPending = !!routeStop?.present && routeStop.processComplete !== true
200+
const processPending = !!routeStop?.present &&
201+
routeStop.retired !== true &&
202+
routeStop.processComplete !== true
190203
const businessPending = requiresBusiness && routeStop?.businessSatisfied === false
191204
const required = processPending || businessPending
192205
const expected = isExpectedRouteAction(routeStop, input.payload || {}, input.contextPost)
@@ -205,7 +218,7 @@ function reconcileProgressiveSkillRoute (state, routeStop, input = {}) {
205218
noProgressCount: 0,
206219
circuitOpen: false,
207220
lastTrigger: trigger,
208-
lastAction: 'complete',
221+
lastAction: routeStop?.retired === true ? 'retired' : 'complete',
209222
lastNoticeFingerprint: '',
210223
updatedAt: new Date().toISOString()
211224
})
@@ -221,7 +234,8 @@ function reconcileProgressiveSkillRoute (state, routeStop, input = {}) {
221234
stateFingerprint: fingerprint,
222235
trigger,
223236
noProgressCount: 0,
224-
circuitOpen: false
237+
circuitOpen: false,
238+
required: false
225239
})
226240
}
227241
}
@@ -249,7 +263,8 @@ function reconcileProgressiveSkillRoute (state, routeStop, input = {}) {
249263
stateFingerprint: fingerprint,
250264
trigger,
251265
noProgressCount,
252-
circuitOpen
266+
circuitOpen,
267+
required: true
253268
})
254269
return {
255270
required: true,

‎hooks/_runtime/lifecycle.cjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1226,7 +1226,7 @@ function evaluateCurrentProgressiveSkillRoute (state, payload, platform, trigger
12261226
routeStop,
12271227
explicitRoutePending
12281228
)
1229-
const effectiveRouteStop = enforce
1229+
const effectiveRouteStop = (enforce || routeStop?.retired === true)
12301230
? routeStop
12311231
: {
12321232
...routeStop,

‎hooks/_runtime/skill-route-state.cjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,7 @@ function loadEnvelope (activeRoot, turnBinding, options = {}) {
704704
if (Date.parse(envelope.expiresAt) <= now) {
705705
const error = new Error('TURN_EXPIRED')
706706
error.code = 'TURN_EXPIRED'
707+
error.routeEnvelope = envelope
707708
throw error
708709
}
709710
return { envelope, paths }

‎hooks/_runtime/skill-route-tool.cjs‎

Lines changed: 143 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1687,6 +1687,101 @@ function handleStatus (input, target, options) {
16871687
return finalizeResponse(response, 16 * 1024)
16881688
}
16891689

1690+
const ROUTE_RETIREMENT_IDENTITY_ERRORS = new Set([
1691+
'PROJECT_BINDING_MISMATCH',
1692+
'CONTEXT_BINDING_MISMATCH',
1693+
'MODE_CAPABILITY_STALE',
1694+
'RUNTIME_CONTRACT_STALE',
1695+
'TURN_EXPIRED'
1696+
])
1697+
1698+
const TRUSTED_BUSINESS_RETIREMENT_ERRORS = new Set([
1699+
'MODE_CAPABILITY_STALE',
1700+
'RUNTIME_CONTRACT_STALE'
1701+
])
1702+
1703+
/**
1704+
* Summarize immutable process obligations and the selected business reply for
1705+
* Stop evaluation without mutating the durable route envelope.
1706+
*
1707+
* @param {object} state persisted route state
1708+
* @param {object} input lifecycle Stop input
1709+
* @param {object} [options] trust controls for a misbound envelope
1710+
* @returns {{pendingStageIds: string[], business: object|null, businessSatisfied: boolean}}
1711+
*/
1712+
function summarizeStopObligations (state, input, options = {}) {
1713+
const requiredStageIds = state.obligationLedger?.requiredStageIds || []
1714+
const stageProgress = state.stageProgress || {}
1715+
const pendingStageIds = requiredStageIds.filter(stageId =>
1716+
stageProgress[stageId]?.status !== 'loaded'
1717+
)
1718+
const business = options.trustBusiness === false
1719+
? null
1720+
: (state.obligationLedger?.items?.find(item =>
1721+
item.skillId === state.obligationLedger.selectedBusinessSkillId
1722+
) || null)
1723+
const mustReplyCore = String(business?.mustReplyCore || '')
1724+
const businessSatisfied = !business || (
1725+
mustReplyCore.length > 0 &&
1726+
String(input.assistantText || '').includes(mustReplyCore)
1727+
)
1728+
return { pendingStageIds, business, businessSatisfied }
1729+
}
1730+
1731+
/**
1732+
* Project a route that cannot execute under the current identity as retired.
1733+
* Retirement preserves incomplete process evidence; it only ends obligations
1734+
* that no current-runtime operation can satisfy.
1735+
*
1736+
* @param {object} state persisted route state
1737+
* @param {object} input lifecycle Stop input
1738+
* @param {string} turnBinding active turn binding
1739+
* @param {Error} error binding failure
1740+
* @returns {object|null} retired Stop projection or null for other failures
1741+
*/
1742+
function buildRetiredRouteStop (state, input, turnBinding, error) {
1743+
const errorCode = error.code || 'SKILL_ROUTE_STOP_BINDING_FAILED'
1744+
if (!ROUTE_RETIREMENT_IDENTITY_ERRORS.has(errorCode)) return null
1745+
const trustBusiness = TRUSTED_BUSINESS_RETIREMENT_ERRORS.has(errorCode)
1746+
const { pendingStageIds, business, businessSatisfied } = summarizeStopObligations(
1747+
state,
1748+
input,
1749+
{ trustBusiness }
1750+
)
1751+
const businessActionRequired = businessSatisfied === false
1752+
return {
1753+
schemaVersion: 'ProgressiveSkillRouteStopV1',
1754+
present: true,
1755+
complete: !businessActionRequired,
1756+
turnBinding,
1757+
contextEpoch: state.contextEpoch,
1758+
planDigest: state.plan?.planDigest || null,
1759+
processComplete: false,
1760+
retired: true,
1761+
retirementReason: errorCode,
1762+
completionDisposition: 'retired-stale-identity',
1763+
pendingStageIds,
1764+
selectedBusinessSkillId: business?.skillId || null,
1765+
mustReplyCore: business?.mustReplyCore || null,
1766+
businessSatisfied,
1767+
businessEvaluation: trustBusiness
1768+
? 'trusted-bound-route'
1769+
: 'not-applicable-untrusted-route-identity',
1770+
errorCode,
1771+
nextOp: businessActionRequired ? 'satisfy_business' : null,
1772+
nextCall: null,
1773+
recovery: {
1774+
schemaVersion: 'SkillRouteRetirementRecoveryV1',
1775+
automatic: !businessActionRequired,
1776+
action: businessActionRequired
1777+
? 'reply-selected-business-core'
1778+
: 'retire-and-allow-stop',
1779+
mustReplyCore: business?.mustReplyCore || null,
1780+
rebootstrapOnNextUserPrompt: true
1781+
}
1782+
}
1783+
}
1784+
16901785
function evaluateProgressiveSkillRouteStop (input, options = {}) {
16911786
const target = resolveProjectTarget(
16921787
options.inputRoot || input.cwd || process.cwd(),
@@ -1709,6 +1804,23 @@ function evaluateProgressiveSkillRouteStop (input, options = {}) {
17091804
turnBinding
17101805
}
17111806
}
1807+
if (error.code === 'TURN_EXPIRED' && error.routeEnvelope?.state) {
1808+
const expiredState = error.routeEnvelope.state
1809+
const requestedHostSessionId = String(input.hostSessionId || '')
1810+
if (requestedHostSessionId &&
1811+
expiredState.hostSessionId &&
1812+
requestedHostSessionId !== expiredState.hostSessionId) {
1813+
return {
1814+
schemaVersion: 'ProgressiveSkillRouteStopV1',
1815+
present: false,
1816+
complete: true,
1817+
turnBinding,
1818+
ignoredReason: 'HOST_SESSION_MISMATCH'
1819+
}
1820+
}
1821+
const retired = buildRetiredRouteStop(expiredState, input, turnBinding, error)
1822+
if (retired) return retired
1823+
}
17121824
return {
17131825
schemaVersion: 'ProgressiveSkillRouteStopV1',
17141826
present: true,
@@ -1718,16 +1830,30 @@ function evaluateProgressiveSkillRouteStop (input, options = {}) {
17181830
}
17191831
}
17201832
const state = envelope.state
1833+
const requestedHostSessionId = String(input.hostSessionId || '')
1834+
if (requestedHostSessionId &&
1835+
state.hostSessionId &&
1836+
requestedHostSessionId !== state.hostSessionId) {
1837+
return {
1838+
schemaVersion: 'ProgressiveSkillRouteStopV1',
1839+
present: false,
1840+
complete: true,
1841+
turnBinding,
1842+
ignoredReason: 'HOST_SESSION_MISMATCH'
1843+
}
1844+
}
17211845
try {
17221846
assertEnvelopeBinding(state, {
17231847
project: target.project,
17241848
turnBinding,
17251849
contextEpoch: input.contextEpoch
17261850
}, target, options)
17271851
} catch (error) {
1728-
const requiredStageIds = state.obligationLedger?.requiredStageIds || []
1729-
const pendingStageIds = requiredStageIds.filter(stageId =>
1730-
state.stageProgress[stageId]?.status !== 'loaded'
1852+
const retired = buildRetiredRouteStop(state, input, turnBinding, error)
1853+
if (retired) return retired
1854+
const { pendingStageIds, business, businessSatisfied } = summarizeStopObligations(
1855+
state,
1856+
input
17311857
)
17321858
return {
17331859
schemaVersion: 'ProgressiveSkillRouteStopV1',
@@ -1738,33 +1864,19 @@ function evaluateProgressiveSkillRouteStop (input, options = {}) {
17381864
planDigest: state.plan?.planDigest || null,
17391865
processComplete: false,
17401866
pendingStageIds,
1741-
businessSatisfied: true,
1867+
selectedBusinessSkillId: business?.skillId || null,
1868+
mustReplyCore: business?.mustReplyCore || null,
1869+
businessSatisfied,
17421870
errorCode: error.code || 'SKILL_ROUTE_STOP_BINDING_FAILED',
17431871
nextOp: null,
1744-
nextCall: null
1745-
}
1746-
}
1747-
const requestedHostSessionId = String(input.hostSessionId || '')
1748-
if (requestedHostSessionId &&
1749-
state.hostSessionId &&
1750-
requestedHostSessionId !== state.hostSessionId) {
1751-
return {
1752-
schemaVersion: 'ProgressiveSkillRouteStopV1',
1753-
present: false,
1754-
complete: true,
1755-
turnBinding,
1756-
ignoredReason: 'HOST_SESSION_MISMATCH'
1872+
nextCall: null,
1873+
recovery: null
17571874
}
17581875
}
1759-
const requiredStageIds = state.obligationLedger?.requiredStageIds || []
1760-
const pendingStageIds = requiredStageIds.filter(stageId =>
1761-
state.stageProgress[stageId]?.status !== 'loaded'
1876+
const { pendingStageIds, business, businessSatisfied } = summarizeStopObligations(
1877+
state,
1878+
input
17621879
)
1763-
const business = state.obligationLedger?.items?.find(item =>
1764-
item.skillId === state.obligationLedger.selectedBusinessSkillId
1765-
) || null
1766-
const businessSatisfied = !business ||
1767-
String(input.assistantText || '').includes(business.mustReplyCore)
17681880
let contextErrorCode = null
17691881
let contextRecovery = null
17701882
if (state.plan && pendingStageIds.length) {
@@ -1828,20 +1940,17 @@ function evaluateProgressiveSkillRouteStop (input, options = {}) {
18281940
}
18291941
}
18301942

1831-
const NON_RECOVERABLE_ROUTE_IDENTITY_ERRORS = new Set([
1832-
'MODE_CAPABILITY_STALE',
1833-
'RUNTIME_CONTRACT_STALE'
1834-
])
1835-
18361943
function shouldEnforceProgressiveSkillRouteStop (routeStop, explicitRoutePending) {
18371944
if (!routeStop?.present || routeStop.complete) return false
1945+
if (routeStop.retired === true) {
1946+
return routeStop.businessSatisfied === false
1947+
}
18381948
if (routeStop.errorCode === 'PLAN_NOT_COMMITTED') {
18391949
return explicitRoutePending === true
18401950
}
1841-
if (NON_RECOVERABLE_ROUTE_IDENTITY_ERRORS.has(routeStop.errorCode)) {
1842-
// Once a plan exists, its required stages are obligations regardless of
1843-
// whether selection was explicit or model-free. Runtime/capability drift
1844-
// must surface a recovery boundary instead of silently discarding them.
1951+
if (ROUTE_RETIREMENT_IDENTITY_ERRORS.has(routeStop.errorCode)) {
1952+
// Legacy projections without an explicit retirement disposition remain
1953+
// fail-closed. Only the current evaluator may retire a bound route.
18451954
return Boolean(
18461955
routeStop.planDigest ||
18471956
(routeStop.pendingStageIds || []).length

0 commit comments

Comments
 (0)