fix(consult): accept a calculation that succeeds after transient failures
Independent Staging Quality Gate / validate (push) Failing after 12m9s
Independent Staging Quality Gate / publish (push) Has been skipped

The runtime contract gated on total consultation tool attempts, so a single
transient calculation failure raised the counter past one and made the run
unrecoverable even after a later attempt succeeded. The already-computed chart
was discarded and the request ended as runtime_contract_incomplete.

Count only successful workflow executions against the single-calculation
boundary. Failed attempts stay in the attempt counter for observability. The
request-scoped cache still retains the successful promise, so a request can
never run more than one billable calculation.

Also record the staging-first branch delivery convention in AGENTS.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-17 12:12:30 +08:00
parent bced1b9d57
commit 7886629b36
5 changed files with 100 additions and 9 deletions
+10 -8
View File
@@ -128,15 +128,17 @@ Deployment safety rules:
## 6. Git Branch Delivery Hard Constraint
当用户要求 push,且开发发生在非 `main` 分支时,默认交付目标是远端
`main` 已包含本次变更,而不是仅把功能分支推到远端:
分支模型:`staging` 是测试环境,`main` 是生产控制分支。所有改动必须先经
`staging` 验收,再提升到 `main`;不存在直接改 `main` 的交付路径。
1. 先 fetch 并同步最新 `origin/main`,不得基于过期的 `main` 合并
2.保护本地未提交修改的前提下,将功能分支合并到最新 `main`
3. 完成与风险相称的测试并确认合并结果后,push `main`
4. 除非用户明确要求只推功能分支,否则不得把“功能分支已 push”当作最终交付,也不得要求用户再去 GitHub 手动寻找分支或创建 PR
5. 推送后必须核对远端 SHA,并验证 `origin/main` 已包含目标提交
6. 工作树有无关脏文件时,使用独立 worktree 完成 `main` 合并;不得 stash、reset、覆盖或顺带提交用户修改
1. 动手前必须 `git fetch origin --prune`,并以远端 **`origin/staging`** 为基线。不得基于本地 `staging` 或本地 `main`:这两个本地引用经常落后远端上百个提交,基于它们做出的分析和补丁会对不上真实代码
2.独立 worktree 中开发,路径 `.worktrees/<主题>-<日期>`,分支 `codex/<主题>-<日期>`。不得在存在未提交修改的工作树上切换分支、stash、reset、覆盖或顺带提交用户变更
3. 交付到 staging 用快进推送(`git push origin HEAD:staging`)。这会触发 Gitea `backend-quality-gate`;该工作流的 `push: branches: [staging]` 没有路径过滤,任何改动(包括纯文档)都会跑完整构建与部署,应合并同批改动一次推送
4. 由 quality gate 构建 digest 固定镜像并 dispatch `deploy-staging`,随后在 `https://staging.jyotisha.chat` 完成与风险相称的验收。`GET /api/health``.deployment.gitCommit` 必须等于本次 SHA,否则视为未部署
5. 提升到 `main` **必须快进,不得 merge**`.gitea/workflows/deploy-production.yml` 强制 `main``staging` 指向同一个 commit SHA;任何 merge commit 都会让生产部署以 `main and staging must identify the same reviewed release` 失败
6. 生产部署手动执行:先跑 `release-quality-gate`,再 dispatch `deploy-production`。它复用 staging 已验收的镜像 digest,不重新构建
7. 推送后必须核对远端 SHA,确认 `origin/staging`(以及提升后的 `origin/main`)确实包含目标提交;远端验证失败时不得声称已交付。
8. GitHub `upstream` 仅为镜像,其工作流已停用,不得用它验证交付状态。
## 7. Bug History Workflow Hard Constraint
+19
View File
@@ -3583,3 +3583,22 @@
- 防复发:对启用 RLS 的管理资源,table grant 与 RLS policy 必须成对验证;后台列表测试必须使用 `admin_runtime` 真实角色,不能只用 schema owner 绕过 RLS。
- 相关记录:BUG-156、BUG-209
- 修复版本:本地未提交候选
## BUG-214 | 计算重试成功后仍误报运行合同未完成并丢弃已算出的星盘
- 状态:resolved(本地修复,未提交、未发布)
- 首次发现:2026-08-17
- 最近更新:2026-08-17
- 影响面:`/api/consult` 个人咨询在 `run-jyotish-consultation` 出现任何一次瞬时失败后的全部后续行为。
- 用户现象:staging 事业类咨询运行约一分钟后返回 `run.failed` / `runtime_contract_incomplete`,提示“Agent 未完成必要的方法与计算步骤,本次不会扣点”。事件流显示 Skill 已加载、星盘实际已计算成功,用户却拿不到任何回答文本。
- 触发条件:`run-jyotish-consultation` 首次调用失败,模型据此重试,并在后续某次调用中成功。观测到的事件序列为两次 `tool.failed calculation_failed`、一次 `tool.completed`(20278ms),随后补跑命中缓存再次 `tool.completed`55ms)。
- 根因:合同门禁 `contractReady()` 要求 `consultationToolCallCount === 1`,而该计数在 `createConsultationTools()` 中对每次未命中缓存的执行递增,失败尝试同样计入。BUG-205 的修复让被拒绝的计算缓存可以释放、从而允许重试,但门禁仍按总尝试次数判定,导致只要发生一次瞬时失败,计数就永久大于 1,之后无论计算是否成功都不可能满足合同。服务端补跑无法降低计数,因此纯属浪费,最终以 `runtime_contract_incomplete` 结束并丢弃已经算出的结果。
- 修复:新增 `consultationToolSuccessCount`,仅在工作流真正成功时递增;门禁改为判定成功次数为 1。失败尝试继续记入 `consultationToolCallCount` 供观测使用,但不再影响合同。未放宽单次计算边界:请求级缓存保留成功 Promise,后续调用一律复用,因此每个请求仍最多执行一次计费计算;两次真实成功计算依然判定为违约。
- 验证:新增修复前失败的回归,复现“两次失败 + 一次成功 + 补跑命中缓存”序列并断言 `run.completed` 且回答正常输出;新增“两次成功仍然违约”边界回归以锁定单次计算约束;工具层补充断言失败重试后 `consultationToolCallCount=2``consultationToolSuccessCount=1`。回退门禁到旧实现可确认新回归失败。`frontend/tests/consultation-agentic-runtime.test.ts` 17/17 通过。
- 防复发:运行合同门禁只能依据成功语义的计数,不得用包含失败尝试的总调用次数;任何允许重试的缓存改动,必须同步检查下游门禁是否仍按尝试次数判定。合同类回归必须覆盖“失败后恢复”与“重复成功”两个方向。
- 相关记录:BUG-205、BUG-186、BUG-189
- 修复版本:本地未提交候选
### 待跟进
前两次 `calculation_failed` 的服务端原因尚未定位,需要 Python API 日志确认(工作流超时为 90s,两次失败均在 20s 内,可排除超时)。本条修复只保证瞬时失败可恢复,不替代对失败本身的排查。
+5 -1
View File
@@ -143,9 +143,13 @@ type StreamAgentResponseOptions = EventOptions & {
onCancel?: (emitted: boolean) => void | Promise<void>;
};
// Failed attempts are retried by the model against the same request-scoped
// calculation cache, so only successful workflow executions may count against
// the single-calculation boundary. Gating on total attempts would make any
// transient failure permanently unrecoverable.
function contractReady(options: StreamAgentResponseOptions) {
return options.state.jyotishSkillLoaded
&& (!options.requireTool || (options.state.consultationToolCompleted && options.state.consultationToolCallCount === 1));
&& (!options.requireTool || (options.state.consultationToolCompleted && options.state.consultationToolSuccessCount === 1));
}
export function streamAgentResponse(options: StreamAgentResponseOptions) {
@@ -48,6 +48,7 @@ export type ConsultationRuntimeState = {
consultationToolStarted: boolean;
consultationToolCompleted: boolean;
consultationToolCallCount: number;
consultationToolSuccessCount: number;
consultationToolDurationMs?: number;
workflowReceipt?: WorkflowReceipt;
techniqueTruth?: string;
@@ -65,6 +66,7 @@ export function createConsultationRuntimeState(options: { plannedSteps?: number;
consultationToolStarted: false,
consultationToolCompleted: false,
consultationToolCallCount: 0,
consultationToolSuccessCount: 0,
steps: [],
stepBudget: { planned, reservedValidation, total: planned + reservedValidation },
stepsTruncated: false,
@@ -242,6 +244,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
data: { phase: "evidence-validation", label: "正在核对可用证据" },
});
ctx.state.consultationToolCompleted = true;
ctx.state.consultationToolSuccessCount += 1;
appendConsultationRuntimeStep(ctx.state, { kind: "tool", name: "run-jyotish-consultation", status: "completed", durationMs: ctx.state.consultationToolDurationMs });
return toModelDomainPlanContext(executions);
} catch (error) {
@@ -72,6 +72,7 @@ test("context-bound tool keeps legacy single theme compatibility and calculates
assert.deepEqual(captured, { ...serverChart.toolInput, entryMode: "direct_chart", question: "事业如何", theme: "career" });
assert.strictEqual(capturedPlan, plan);
assert.equal(state.consultationToolCallCount, 1);
assert.equal(state.consultationToolSuccessCount, 1);
assert.equal(state.workflowReceipt?.preciseTiming, "allowed");
assert.deepEqual(state.workflowReceipt?.domains, ["career"]);
assert.deepEqual((first as { domains?: string[] }).domains, ["career"]);
@@ -175,11 +176,13 @@ test("invalid model input does not poison a later valid contract retry", async (
);
assert.equal(calls, 0);
assert.equal(state.consultationToolCallCount, 0);
assert.equal(state.consultationToolSuccessCount, 0);
const result = await tool.execute!({ question: "改用合法参数", theme: "timing" }, context) as { domains: string[] };
assert.equal(calls, 1);
assert.deepEqual(result.domains, ["timing"]);
assert.equal(state.consultationToolCallCount, 1);
assert.equal(state.consultationToolSuccessCount, 1);
assert.equal(state.consultationToolCompleted, true);
});
@@ -205,6 +208,8 @@ test("a rejected workflow promise is cleared before a later tool call", async ()
assert.equal(calls, 2);
assert.deepEqual(result.domains, ["timing"]);
assert.equal(state.consultationToolCallCount, 2);
assert.equal(state.consultationToolSuccessCount, 1);
});
test("personal Agent exposes the Jyotish Skill and named server tool", async () => {
@@ -310,6 +315,7 @@ test("holds answer text until the Skill and server tool contract completes", asy
yield { type: "tool-result", payload: { toolCallId: "skill-1", toolName: "skill", result: {} } };
yield { type: "tool-call", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", args: {} } };
state.consultationToolCallCount = 1;
state.consultationToolSuccessCount = 1;
state.consultationToolCompleted = true;
state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] };
yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } };
@@ -328,6 +334,61 @@ test("holds answer text until the Skill and server tool contract completes", asy
assert.equal((events.find((event) => (event as { type?: string }).type === "answer.delta") as { text?: string }).text, "只在合同完成后显示。");
});
test("a calculation that succeeds only after failed attempts still satisfies the contract", async () => {
const state = createConsultationRuntimeState();
let completed = 0;
async function* chunks() {
yield { type: "tool-call", payload: { toolCallId: "skill-1", toolName: "skill", args: { name: "jyotish-vedic-astrology" } } };
state.jyotishSkillLoaded = true;
yield { type: "tool-result", payload: { toolCallId: "skill-1", toolName: "skill", result: {} } };
// Two transient workflow failures, then one success, as observed in production.
state.consultationToolCallCount = 3;
state.consultationToolSuccessCount = 1;
state.consultationToolCompleted = true;
state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] };
yield { type: "tool-result", payload: { toolCallId: "tool-3", toolName: "run-jyotish-consultation", result: {} } };
yield { type: "text-delta", payload: { text: "事业方向的判断如下。" } };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
toolStatus: () => "ready", receipt: () => receipt(state),
onComplete: () => { completed += 1; },
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
assert.equal(completed, 1);
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 0);
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
assert.equal((events.find((event) => (event as { type?: string }).type === "answer.delta") as { text?: string }).text, "事业方向的判断如下。");
});
test("a second successful calculation still fails the single-calculation boundary", async () => {
const state = createConsultationRuntimeState();
let failed = 0;
async function* chunks() {
yield { type: "tool-call", payload: { toolCallId: "skill-1", toolName: "skill", args: { name: "jyotish-vedic-astrology" } } };
state.jyotishSkillLoaded = true;
yield { type: "tool-result", payload: { toolCallId: "skill-1", toolName: "skill", result: {} } };
state.consultationToolCallCount = 2;
state.consultationToolSuccessCount = 2;
state.consultationToolCompleted = true;
state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] };
yield { type: "text-delta", payload: { text: "不应显示" } };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
toolStatus: () => "ready", receipt: () => receipt(state),
onError: () => { failed += 1; },
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
assert.equal(failed, 1);
assert.equal(events.some((event) => (event as { type?: string }).type === "answer.delta"), false);
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 1);
});
test("incomplete runtime contract fails without saving a successful answer", async () => {
const state = createConsultationRuntimeState();
let completed = 0;
@@ -354,6 +415,7 @@ test("ensures a controlled final response after a successful tool-only run", asy
const state = createConsultationRuntimeState();
state.jyotishSkillLoaded = true;
state.consultationToolCallCount = 1;
state.consultationToolSuccessCount = 1;
state.consultationToolCompleted = true;
state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] };
let completedOutput = "";
@@ -380,6 +442,7 @@ test("persistence failure emits run.failed instead of run.completed", async () =
const state = createConsultationRuntimeState();
state.jyotishSkillLoaded = true;
state.consultationToolCallCount = 1;
state.consultationToolSuccessCount = 1;
state.consultationToolCompleted = true;
state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] };
let failed = 0;