fix(consult): accept a calculation that succeeds after transient failures
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 内,可排除超时)。本条修复只保证瞬时失败可恢复,不替代对失败本身的排查。
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user