Take in the engine zone's work on calls the engine cuts itself, as the zone built it: a delivered break is asked about once, paid once, and never bought twice
This commit is contained in:
parent
6ee6c61eaa
commit
3f05fab941
28 changed files with 7621 additions and 142 deletions
|
|
@ -1,6 +1,8 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"textmachine/backend/internal/llm"
|
||||
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
|
@ -486,3 +488,45 @@ func TestACrashJoinedWithACeilingStillExitsAsACrash(t *testing.T) {
|
|||
t.Fatalf("a crash carrying a ceiling exited %d, want 1 — exit 4 would tell a supervisor to resume a process that died", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheCutErrorTypeKeepsItsExitCode pins the linkage between the pack's new error type and the number
|
||||
// a caller reads. Nothing held it: `AttemptCutError` appeared 0 times in this package's tests against 26
|
||||
// calls of the exit mapping, so every claim about «a stop exits 5, a cut exits 1» rested on reading the
|
||||
// switch rather than on running it.
|
||||
//
|
||||
// The distinction is the whole point of the type. All three causes are one Go type, and two of them are
|
||||
// ordinary failures while the third is a person pressing stop — if the mapping stopped telling them
|
||||
// apart, an operator's stop would report as an engine failure, or an engine failure as a stop.
|
||||
func TestTheCutErrorTypeKeepsItsExitCode(t *testing.T) {
|
||||
cut := func(cause llm.CutCause, parent error) error {
|
||||
return &llm.AttemptCutError{Provider: "p", Cause: cause, Delivered: true,
|
||||
Err: errors.New("transport"), Parent: parent}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{"a person stopped the run", cut(llm.CutByParent, context.Canceled), 5},
|
||||
{"our own deadline cut the call", cut(llm.CutBySelfDeadline, nil), 1},
|
||||
{"the socket died after delivery", cut(llm.CutByConnection, nil), 1},
|
||||
// Wrapped the way the engine actually hands it up: through fmt.Errorf and a join.
|
||||
{"a stop wrapped by the pipeline", fmt.Errorf("pipeline: stage edit call: %w",
|
||||
cut(llm.CutByParent, context.Canceled)), 5},
|
||||
{"a stop joined with what it interrupted", errors.Join(context.Canceled,
|
||||
cut(llm.CutByConnection, nil)), 5},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := exitCode(c.err); got != c.want {
|
||||
t.Errorf("%s: exit %d, want %d — the three causes share one Go type and two of them are "+
|
||||
"ordinary failures while the third is a person pressing stop; a mapping that stops "+
|
||||
"telling them apart reports a stop as an engine failure or the reverse", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
// The control: a ceiling still outranks a cut that rode along with it. A money stop is a different
|
||||
// event from a transport one, and the caller's runbook branches on it.
|
||||
ceiling := errors.Join(&pipeline.CeilingHalt{}, cut(llm.CutByConnection, nil))
|
||||
if got := exitCode(ceiling); got != 4 {
|
||||
t.Errorf("a ceiling halt carrying a cut must still exit 4, got %d", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -517,6 +517,11 @@ func errTail(degraded, errText string) string {
|
|||
}
|
||||
s += errText
|
||||
}
|
||||
// ⛔ ONE ROW IS ONE LINE. `errors.Join` separates its members with a newline, and an engine error
|
||||
// that carries both a cut and what ended the chain is exactly such a join — so a table row printed
|
||||
// its tail across two lines, breaking the column alignment of everything under it and pushing the
|
||||
// second half where no reader looks for it.
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if len(s) > 120 {
|
||||
cut := 120
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
|
|
@ -875,6 +880,13 @@ func renderStatusHuman(w io.Writer, rep *pipeline.StatusReport, cfgPath string)
|
|||
}
|
||||
fmt.Fprintf(w, "Money: committed=$%.6f reserved=$%.6f%s · book forecast ~$%.6f\n",
|
||||
rep.CommittedUSD, rep.ReservedUSD, ceil, rep.ProjectedBookUSD)
|
||||
// The estimated share of that committed figure, on the surface an operator reads before deciding
|
||||
// whether to keep paying. The same pair rides in --json for the platform; printing it here too is
|
||||
// what makes «committed» readable as a range rather than as a measurement.
|
||||
if rep.EstimatedRows > 0 {
|
||||
fmt.Fprintf(w, " of which ESTIMATED: $%.6f over %d call(s) — booked at the reservation estimate because the provider never reported usage for them (a body that would not decode, a call our own deadline or a stop cut short, a 2xx with no usage)\n",
|
||||
rep.EstimatedUSD, rep.EstimatedRows)
|
||||
}
|
||||
if rep.ETASeconds > 0 {
|
||||
fmt.Fprintf(w, "ETA: ~%.0fs (mean throughput of fresh calls, NOT EWMA — D12-deviation; secondary)\n", rep.ETASeconds)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -588,3 +588,109 @@ func TestRenderSignatureStopShowsBothDisagreements(t *testing.T) {
|
|||
t.Errorf("control: an unmarked row must print neither marker:\n%s", quiet.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusJSONPublishesTheEstimatedShareBesideCommitted is the SEAM contract, not a formatting test.
|
||||
// The platform bills a user `committed_usd` from this JSON, and until the pair below existed it could
|
||||
// say «the run cost at least this much» and never «at least X, up to Y» (PD-441). The owner's word on
|
||||
// charging a reader for a call we cut short came with a condition — that the estimate be visible as an
|
||||
// estimate (D39.230 п.1) — and for the platform «visible» means a NUMBER, not a line of screen text.
|
||||
//
|
||||
// ⛔ ZERO MUST PUBLISH AS ZERO. Rendered with `omitempty` the fields would vanish on a clean book, and a
|
||||
// consumer cannot tell «none of it was estimated» from «this engine is too old to know»: the safe read
|
||||
// of an absent field is the pessimistic one, so a perfectly measured book would be billed as uncertain.
|
||||
func TestStatusJSONPublishesTheEstimatedShareBesideCommitted(t *testing.T) {
|
||||
decode := func(t *testing.T, rep *pipeline.StatusReport) map[string]any {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
if err := renderStatusJSON(&b, rep); err != nil {
|
||||
var flagged *pipeline.CompletedWithFlags
|
||||
if !errors.As(err, &flagged) {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
}
|
||||
var decoded map[string]any
|
||||
if jerr := json.Unmarshal([]byte(b.String()), &decoded); jerr != nil {
|
||||
t.Fatalf("output must be valid JSON: %v", jerr)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
cut := decode(t, &pipeline.StatusReport{TotalUnits: 1, Done: 1, CommittedUSD: 0.5, EstimatedRows: 2, EstimatedUSD: 0.25})
|
||||
for field, want := range map[string]any{"committed_usd": 0.5, "estimated_usd": 0.25, "estimated_rows": float64(2)} {
|
||||
if cut[field] != want {
|
||||
t.Fatalf("the platform reads %q from this JSON: got %v, want %v (full: %v)", field, cut[field], want, cut)
|
||||
}
|
||||
}
|
||||
clean := decode(t, &pipeline.StatusReport{TotalUnits: 1, Done: 1, CommittedUSD: 0.5})
|
||||
for _, field := range []string{"estimated_usd", "estimated_rows"} {
|
||||
v, present := clean[field]
|
||||
if !present {
|
||||
t.Fatalf("%q vanished on a book with nothing estimated — a consumer cannot tell that from an "+
|
||||
"engine that does not publish it, and the safe reading of the absence is the wrong one", field)
|
||||
}
|
||||
if v != float64(0) {
|
||||
t.Fatalf("%q on a fully measured book must be 0, got %v", field, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusHumanShowsTheEstimatedShare is the human twin of the JSON seam, and it had no pin at all:
|
||||
// removing the whole block left both packages green. It is the surface an operator reads before
|
||||
// deciding whether to keep paying, and the number beside `committed` is what makes that figure a RANGE
|
||||
// rather than a measurement — the condition the owner's word came with (D39.230 п.1).
|
||||
//
|
||||
// Both sides, because a line that always prints is as useless as one that never does.
|
||||
func TestStatusHumanShowsTheEstimatedShare(t *testing.T) {
|
||||
render := func(t *testing.T, rep *pipeline.StatusReport) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
if err := renderStatusHuman(&b, rep, "book.yaml"); err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
withEst := render(t, &pipeline.StatusReport{TotalUnits: 1, Done: 1, CommittedUSD: 0.5, EstimatedRows: 2, EstimatedUSD: 0.25})
|
||||
if !strings.Contains(withEst, "ESTIMATED") {
|
||||
t.Fatalf("an operator deciding whether to keep paying must see which part of `committed` is an "+
|
||||
"estimate; the screen said nothing:\n%s", withEst)
|
||||
}
|
||||
if !strings.Contains(withEst, "0.250000") || !strings.Contains(withEst, "2 call") {
|
||||
t.Fatalf("the line must carry BOTH the money and the call count — one expensive call and twenty "+
|
||||
"cheap ones are different problems with the same figure:\n%s", withEst)
|
||||
}
|
||||
// The control: on a book with nothing estimated the line must be absent, or it becomes noise an
|
||||
// operator learns to scroll past — and then it is not there on the day it matters.
|
||||
clean := render(t, &pipeline.StatusReport{TotalUnits: 1, Done: 1, CommittedUSD: 0.5})
|
||||
if strings.Contains(clean, "ESTIMATED") {
|
||||
t.Fatalf("a fully measured book must print no estimate line:\n%s", clean)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheOperatorTailIsOneLineAndKeepsTheNoteThatMatters pins the column an operator actually reads.
|
||||
// Two independent ways it failed: an engine error built by errors.Join carries a NEWLINE, so one table
|
||||
// row printed as two and broke the alignment of everything under it; and the note the cut path appends —
|
||||
// how many times the provider was asked, and what was booked — sat at the END of a transport error
|
||||
// routinely longer than the 120-byte bound, so it reached nobody.
|
||||
func TestTheOperatorTailIsOneLineAndKeepsTheNoteThatMatters(t *testing.T) {
|
||||
long := strings.Repeat("провайдер разорвал соединение на середине тела ответа. ", 6)
|
||||
note := "[the provider was asked 3 times; NOTHING is booked: the provider acknowledged none of them]"
|
||||
// The newline sits right behind the note, INSIDE the bound: put it past 120 bytes and the truncation
|
||||
// removes it for free, and the fixture would pass on a renderer that collapses nothing.
|
||||
tail := errTail("connection_lost", note+"\n"+long)
|
||||
|
||||
if strings.ContainsAny(tail, "\n\r") {
|
||||
t.Fatalf("one row is one line: a joined error put a newline into the column and the table split "+
|
||||
"under it. got %q", tail)
|
||||
}
|
||||
if !strings.Contains(tail, "asked 3 times") {
|
||||
t.Fatalf("the note is the only place the gap between what was generated and what was booked is "+
|
||||
"visible; the bound must not be what removes it. got %q", tail)
|
||||
}
|
||||
if !strings.Contains(tail, "NOTHING is booked") {
|
||||
t.Fatalf("the note must survive whole enough to be read, not just start: %q", tail)
|
||||
}
|
||||
// The control: the bound is still a bound, or this test would pass on a renderer that stopped
|
||||
// truncating and let a whole provider body into the table.
|
||||
if len([]rune(tail)) > 121 {
|
||||
t.Fatalf("the tail must still be bounded: %d runes", len([]rune(tail)))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -56,14 +56,44 @@ providers:
|
|||
# xai в ДАННЫЕ не вносил осознанно — это сняло бы grok-off-выключатель, чья цена (grok думает по
|
||||
# дефолту, additive) — решение владельца, а не сессии.
|
||||
echoes_when_thinking_off: true
|
||||
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
|
||||
# ⚠️ attempt_s — ПОЛ дедлайна, а не сам дедлайн: реальный считается от бюджета вызова
|
||||
# (queue_slack_s + max_tokens/tok_s_floor, зажатый между attempt_s и attempt_max_s). Прежнее
|
||||
# чтение «240 — это дедлайн» было верно ровно для ЧЕРНОВИКА и перенесено на редактора вслепую:
|
||||
# 240 с — то, что вендорская формула 128 000 ток/час даёт для ~8.5k, а редактор бюджетируется на
|
||||
# 16 000 с удвоением до 32 000, то есть на вызов, который в 240 с не уложится ни на какой скорости.
|
||||
#
|
||||
# tok_s_floor: 50 — ЗАМЕРЕНО по нашему же request_log, а не назначено. p10 скорости
|
||||
# deepseek-v4-pro (самая медленная модель этого провайдера) = 51.40 ток/с на n=1745 строках
|
||||
# 162 баз с непустыми completion_tokens и latency_ms; округление ВНИЗ до 50 объявлено здесь.
|
||||
# Контроль: deepseek-v4-flash на тех же данных даёт p10 = 88.15 (n=6361), то есть пол держит обе.
|
||||
# ⚠️ Замер берёт latency КОНЦА В КОНЕЦ, вместе с ожиданием в очереди, — значит он ЗАНИЖАЕТ
|
||||
# реальную скорость генерации, и пол от него консервативен в правильную сторону.
|
||||
#
|
||||
# queue_slack_s: 600 — число ВЕНДОРА, не наше: DeepSeek документирует ранний 200 с пустыми
|
||||
# строками, пока запрос ждёт планирования, и закрытие соединения, если инференс не начался за
|
||||
# 10 минут (api-docs.deepseek.com/quick_start/rate_limit). Меньше ставить нельзя — это молча
|
||||
# переигрывало бы принятый владельцем размен «ждать до ~20 минут» в сторону обрыва оплаченного.
|
||||
#
|
||||
# attempt_max_s: 1240 = 600 + 32000/50 — слак плюс максимальный грант С УДВОЕНИЕМ на полу.
|
||||
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60, tok_s_floor: 50, queue_slack_s: 600, attempt_max_s: 1240 }
|
||||
|
||||
zai: # GLM, международный контур (docs.z.ai)
|
||||
kind: openai
|
||||
base_url: https://api.z.ai/api/paas/v4
|
||||
api_key_env: ZAI_API_KEY
|
||||
reasoning: subset
|
||||
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
|
||||
# tok_s_floor: 35 — ЗАМЕРЕНО тем же прибором, что и deepseek: p10 скорости glm-5 = 37.51 ток/с на
|
||||
# n=647 строках request_log (≥100, как требует правило вывода), округление ВНИЗ до 35 объявлено
|
||||
# здесь. Ниже вендорского дефолта 35.5, то есть ставит вызову чуть БОЛЬШЕ времени, а не меньше.
|
||||
# queue_slack_s НЕ ставится: документированного z.ai числа ожидания до инференса у меня нет, а
|
||||
# гадать правило запрещает — слак остаётся нулевым, как и был.
|
||||
# ⚠ ОТСТУПЛЕНИЕ, ОБЪЯВЛЕНО: пол замерен по glm-5, а НЕ по glm-5.1, которая тоже ходит через этого
|
||||
# провайдера и наследует то же число без собственного замера. Отступление принято потому, что
|
||||
# направление ошибки безопасно: 35 ниже вендорского дефолта 35.5, то есть даже неверный для 5.1 пол
|
||||
# даёт вызову БОЛЬШЕ времени, чем дефолт, а не меньше — то есть режет не вызовы, а только запас.
|
||||
# Убрать отступление можно одним способом: собрать n≥100 строк request_log по glm-5.1 и объявить её
|
||||
# собственный p10 здесь. До тех пор число читать как «пол провайдера по измеренной модели».
|
||||
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60, tok_s_floor: 35 }
|
||||
|
||||
kimi: # API-хост — api.moonshot.ai (intl, НЕ .cn). Веб-консоль переехала
|
||||
# platform.moonshot.ai → platform.kimi.ai, но API-эндпоинт остаётся
|
||||
|
|
@ -73,7 +103,13 @@ providers:
|
|||
base_url: https://api.moonshot.ai/v1
|
||||
api_key_env: KIMI_API_KEY
|
||||
reasoning: subset
|
||||
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
|
||||
# tok_s_floor НЕ ставится: строк этого провайдера в нашем request_log НОЛЬ (замер по 162 базам —
|
||||
# deepseek 8106, glm 647, mistral 14, kimi 0), а назначать скорость без замера правило вывода
|
||||
# запрещает. Дедлайн считается от вендорского дефолта 128 000 ток/час, который медленнее всего,
|
||||
# что мы мерили, и потому только УДЛИНЯЕТ вызов; движок пишет об этом WARN на первом же вызове.
|
||||
# attempt_max_s: 1200 — не замер, а ПОЛИТИКА: ратифицированный владельцем предел ожидания ~20 мин.
|
||||
# Он ничего не режет — дефолтный пол на удвоенном гранте 16 000 даёт 900 с.
|
||||
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60, attempt_max_s: 1200 }
|
||||
|
||||
xai: # Grok — главный тир канала B (18+) + судья 18+; из редакторских ролей СНЯТ (D30.1 — reasoning-off no-op).
|
||||
# ⚠️ КЛЮЧЕВАЯ ПОЛИТИКА (D27, супрсидит «чистый ключ» D19.4/D20.3): ЕДИНЫЙ XAI_API_KEY С data-sharing
|
||||
|
|
@ -135,7 +171,10 @@ providers:
|
|||
# Поэтому склеиваем сами — это единственная форма, которая потерять не может.
|
||||
capabilities:
|
||||
system_messages: single
|
||||
timeouts: { attempt_s: 300, max_attempts: 3, backoff_cap_s: 60 } # апекс думает дольше
|
||||
# attempt_s 300 — апекс думает дольше; как и везде, это ПОЛ. tok_s_floor не ставится по той же
|
||||
# причине, что у kimi: строк gemini в нашем request_log ноль. attempt_max_s — предел ожидания,
|
||||
# ратифицированный владельцем (~20 мин); дефолтный пол на удвоенном гранте 16 000 даёт 900 с.
|
||||
timeouts: { attempt_s: 300, max_attempts: 3, backoff_cap_s: 60, attempt_max_s: 1200 }
|
||||
|
||||
openai: # OpenAI прямой ключ (D3: Anthropic убран, OpenAI ОСТАЁТСЯ). gpt-5-nano альт-черновик,
|
||||
# gpt-5-mini кандидат-редактор/second-opinion судья — НЕ в дефолтной цепочке Ф1 (слот под Ф2).
|
||||
|
|
|
|||
|
|
@ -122,16 +122,36 @@ type ReasoningCapCfg struct {
|
|||
// Timeouts is the retry profile per provider (the per-provider profile from the
|
||||
// validation verdict; per-role overrides — Phase 1).
|
||||
type Timeouts struct {
|
||||
// AttemptS is the FLOOR of one attempt's deadline, not the deadline itself: the real one is derived
|
||||
// from the output budget the call carries (llm/attemptcut.go). Read as a fixed value it was wrong by
|
||||
// construction for the editor — 240 s is what the vendor's own 128 000-tokens-per-hour figure gives
|
||||
// for the DRAFT's ~8.5k budget, and the same number was carried to a stage budgeted at 16 000 with a
|
||||
// doubling to 32 000, i.e. to a call that could not finish inside it at any speed the model holds.
|
||||
AttemptS int `yaml:"attempt_s"`
|
||||
MaxAttempts int `yaml:"max_attempts"`
|
||||
BackoffCapS int `yaml:"backoff_cap_s"`
|
||||
// TokSFloor is the slowest generation speed this provider has been OBSERVED to hold — below the p10
|
||||
// of its own request_log, rounded down, and the rounding declared where it is set. Unset ⇒ the
|
||||
// vendor default (128 000 tokens/hour), which is slower than any provider we have measured and
|
||||
// therefore only ever grants a call MORE time than it needs.
|
||||
TokSFloor float64 `yaml:"tok_s_floor"`
|
||||
// QueueSlackS is how long the VENDOR documents a request may wait before generation starts. It is
|
||||
// the vendor's number and not a guess: a slack smaller than what the vendor publishes silently
|
||||
// re-decides how long we are willing to wait, in the direction of cutting calls we have paid for.
|
||||
QueueSlackS int `yaml:"queue_slack_s"`
|
||||
// AttemptMaxS caps the derived deadline — the longest a single call may hold a reservation. 0 =
|
||||
// uncapped, i.e. the derivation stands on its own.
|
||||
AttemptMaxS int `yaml:"attempt_max_s"`
|
||||
}
|
||||
|
||||
func (t Timeouts) Profile() llm.RetryProfile {
|
||||
return llm.RetryProfile{
|
||||
AttemptTimeout: time.Duration(t.AttemptS) * time.Second,
|
||||
MaxAttempts: t.MaxAttempts,
|
||||
BackoffCap: time.Duration(t.BackoffCapS) * time.Second,
|
||||
AttemptTimeout: time.Duration(t.AttemptS) * time.Second,
|
||||
MaxAttempts: t.MaxAttempts,
|
||||
BackoffCap: time.Duration(t.BackoffCapS) * time.Second,
|
||||
TokensPerSecFloor: t.TokSFloor,
|
||||
QueueSlack: time.Duration(t.QueueSlackS) * time.Second,
|
||||
AttemptMax: time.Duration(t.AttemptMaxS) * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -238,6 +258,7 @@ func LoadModels(path string) (*Models, error) {
|
|||
if p.Kind == "anthropic" && p.CacheTTL != "" && p.CacheTTL != "5m" && p.CacheTTL != "1h" {
|
||||
bad("provider %s: cache_ttl must be 5m|1h, got %q", name, p.CacheTTL)
|
||||
}
|
||||
validateTimeouts(bad, name, p.Timeouts)
|
||||
if p.Kind == "local" && p.Model == "" {
|
||||
bad("provider %s: local kind requires model (its own tag)", name)
|
||||
}
|
||||
|
|
@ -462,6 +483,17 @@ func (m *Models) ResolveCapability(modelName string) llm.Capability {
|
|||
return resolved
|
||||
}
|
||||
|
||||
// AttemptDeadline is how long ONE call to this model may run: the provider's retry profile applied to
|
||||
// the call's own output budget (llm.RetryProfile.DeadlineFor). It resolves model→provider→timeouts the
|
||||
// same way ResolveCapability and MinMaxTokens resolve their fields, so a caller that needs to SAY how
|
||||
// long a call may wait reads the same answer the transport will act on rather than assembling one.
|
||||
//
|
||||
// An unknown model yields the zero profile, whose derivation still returns the vendor default — the
|
||||
// same direction every fallback on this path takes: too long, never too short.
|
||||
func (m *Models) AttemptDeadline(modelName string, maxTokens int) time.Duration {
|
||||
return m.Providers[m.Models[modelName].Provider].Timeouts.Profile().DeadlineFor(maxTokens)
|
||||
}
|
||||
|
||||
// thinkingControlExtraKeys are keys whose purpose is to toggle a provider's thinking
|
||||
// on the wire — top-level (GLM/DeepSeek {"thinking":…}, Qwen {"enable_thinking":…}, a
|
||||
// raw {"reasoning_effort":…}) OR nested (DeepSeek-V3.1+ disables via
|
||||
|
|
@ -620,6 +652,37 @@ func containsLabel(set []string, want string) bool {
|
|||
// how "accepts none" is written), and a duplicate is a config smell worth naming rather than absorbing
|
||||
// silently. Labels are compared verbatim, so leading/trailing space would make two spellings of one
|
||||
// permission — rejected rather than trimmed, because a permission must not be guessed at.
|
||||
// maxAttemptSeconds bounds every deadline knob a provider may set. It is a TYPO GUARD, not a policy: the
|
||||
// owner ratified waiting up to about twenty minutes for one call, and this sits an order of magnitude
|
||||
// above that so a legitimate configuration is never refused. What it refuses is a slipped digit —
|
||||
// `queue_slack_s: 6000` reads as an hour and forty minutes of waiting nobody chose, and the only place
|
||||
// that shows up is a run that looks hung.
|
||||
const maxAttemptSeconds = 4 * 60 * 60
|
||||
|
||||
// validateTimeouts checks the deadline knobs AGAINST EACH OTHER, which is the part no per-field check
|
||||
// can do. The derivation clamps the derived deadline up to `attempt_s` and then down to `attempt_max_s`,
|
||||
// so a cap below the floor wins — and the floor's own doccomment, which calls it a FLOOR, quietly stops
|
||||
// being true for every call that provider makes. That is a config a person can write in one keystroke
|
||||
// and cannot see afterwards: nothing logs it, and the calls simply get less time than the file says.
|
||||
func validateTimeouts(bad func(string, ...any), name string, t Timeouts) {
|
||||
if t.AttemptS < 0 || t.AttemptS > maxAttemptSeconds {
|
||||
bad("provider %s: attempt_s %d is out of range (0..%d seconds)", name, t.AttemptS, maxAttemptSeconds)
|
||||
}
|
||||
if t.QueueSlackS < 0 || t.QueueSlackS > maxAttemptSeconds {
|
||||
bad("provider %s: queue_slack_s %d is out of range (0..%d seconds)", name, t.QueueSlackS, maxAttemptSeconds)
|
||||
}
|
||||
if t.AttemptMaxS < 0 || t.AttemptMaxS > maxAttemptSeconds {
|
||||
bad("provider %s: attempt_max_s %d is out of range (0..%d seconds)", name, t.AttemptMaxS, maxAttemptSeconds)
|
||||
}
|
||||
if t.TokSFloor < 0 {
|
||||
bad("provider %s: tok_s_floor %g is negative — a speed floor below zero grants a call infinite time", name, t.TokSFloor)
|
||||
}
|
||||
if t.AttemptMaxS > 0 && t.AttemptMaxS < t.AttemptS {
|
||||
bad("provider %s: attempt_max_s %d is BELOW attempt_s %d — the cap would win and every call would "+
|
||||
"get less time than the declared floor, silently", name, t.AttemptMaxS, t.AttemptS)
|
||||
}
|
||||
}
|
||||
|
||||
func validateLabelSet(bad func(string, ...any), where string, set *[]string) {
|
||||
if set == nil {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -185,3 +185,166 @@ models:
|
|||
t.Fatalf("a declared `single` MUST move the snapshot bytes — that is what makes the flip a loud --resnapshot: %s", singleJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAProviderThatWillWaitLongSaysSoInTheCatalog is the second half of the derived deadline, and it
|
||||
// is the half that answers the standing review question — «does a provider the repository has never
|
||||
// seen work without editing Go».
|
||||
//
|
||||
// It does, and this is the price: a provider whose models declare a big enough max_tokens FLOOR is
|
||||
// declaring that its calls are big, and a big call under the vendor DEFAULT speed derives a deadline
|
||||
// far past whatever attempt_s the config carries. That is not a failure — the derivation is
|
||||
// deliberately slower than any provider we have measured, so it only ever grants MORE time — but it
|
||||
// is a quarter of an hour of waiting that the config never mentions, and an operator who reads
|
||||
// `attempt_s: 240` and watches a call run for fifteen minutes has been told something untrue by his
|
||||
// own configuration.
|
||||
//
|
||||
// So the catalogue has to say ONE of two things about such a provider, and either is a line of DATA:
|
||||
// - `tok_s_floor` — the speed it actually holds, measured from its own request_log; or
|
||||
// - `attempt_max_s` — how long we are willing to wait for it, which is a policy and needs no measurement.
|
||||
//
|
||||
// ⛔ IT DOES NOT DEMAND THE MEASUREMENT. Requiring `tok_s_floor` would force a number to be invented
|
||||
// for every provider nobody has run yet, and a fabricated floor is worse than an honest default: it
|
||||
// would cut real calls we had already paid for. The alternative is the point.
|
||||
func TestAProviderThatWillWaitLongSaysSoInTheCatalog(t *testing.T) {
|
||||
m, err := LoadModels(shippedModelsYAML)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
// The largest budget one call to each provider can carry: its biggest declared floor, doubled once
|
||||
// by a regeneration. It is a LOWER bound on what a real run may ask for (a long chunk derives more
|
||||
// than the floor), which is the conservative direction for a gate: it can only under-report who
|
||||
// needs a line, never demand one nobody needs.
|
||||
biggest := map[string]int{}
|
||||
for name, mod := range m.Models {
|
||||
if floor := m.MinMaxTokens(name); floor*2 > biggest[mod.Provider] {
|
||||
biggest[mod.Provider] = floor * 2
|
||||
}
|
||||
}
|
||||
checked, flagged := 0, 0
|
||||
for prov, grant := range biggest {
|
||||
if grant == 0 {
|
||||
continue // no model of this provider declares a floor: nothing to say
|
||||
}
|
||||
p := m.Providers[prov]
|
||||
derived := p.Timeouts.Profile().DeadlineFor(grant)
|
||||
configured := time.Duration(p.Timeouts.AttemptS) * time.Second
|
||||
if derived <= configured {
|
||||
continue // every call fits inside the deadline the config already names
|
||||
}
|
||||
checked++
|
||||
if p.Timeouts.TokSFloor <= 0 && p.Timeouts.AttemptMaxS <= 0 {
|
||||
flagged++
|
||||
t.Errorf("provider %q can be asked for %d output tokens, which derives a %s deadline against "+
|
||||
"its configured attempt_s of %s — declare timeouts.tok_s_floor (measure it from this "+
|
||||
"provider's own request_log) or timeouts.attempt_max_s (how long we are willing to wait)",
|
||||
prov, grant, derived, configured)
|
||||
}
|
||||
}
|
||||
// The control beside the negative: «0 providers need a line» and «the loop never ran» print the
|
||||
// same on a green test, and only one of them means anything.
|
||||
t.Logf("providers in the catalogue: %d; with a declared token floor: %d; whose biggest call outgrows "+
|
||||
"its own attempt_s (and therefore must declare one of the two fields): %d; missing both: %d",
|
||||
len(m.Providers), len(biggest), checked, flagged)
|
||||
if checked == 0 {
|
||||
t.Fatalf("no provider in the shipped catalogue outgrows its own attempt_s — this gate is asking a " +
|
||||
"question of nothing, and would stay green through any regression in the derivation")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryDeclaredDeadlineKnobStaysDeclared pins the deadline data as data. Each of these numbers was
|
||||
// PAID FOR — a speed floor is the p10 of that provider's own request_log over hundreds of rows, a queue
|
||||
// slack is the vendor's published figure — and none of them is reachable by the gate above: it walks the
|
||||
// LOADED struct, where a deleted line and a never-declared field are the same zero, and it skips a
|
||||
// provider whose models declare no token floor at all. So deleting `tok_s_floor: 35` from zai passed
|
||||
// every case in this package.
|
||||
//
|
||||
// The consequence of losing one is quiet by construction. A missing speed floor falls back to the vendor
|
||||
// default, which is within two percent of zai's measured value — the run does not break, the measurement
|
||||
// is simply gone, and the next person re-derives it from scratch or does without.
|
||||
func TestEveryDeclaredDeadlineKnobStaysDeclared(t *testing.T) {
|
||||
m, err := LoadModels(shippedModelsYAML)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
type knobs struct {
|
||||
tokSFloor float64
|
||||
queueSlackS int
|
||||
attemptMaxS int
|
||||
}
|
||||
// The catalogue as it ships. A change here is a change to a measured or vendor-published number and
|
||||
// must be made deliberately, with the measurement said beside it in models.yaml.
|
||||
want := map[string]knobs{
|
||||
"deepseek": {tokSFloor: 50, queueSlackS: 600, attemptMaxS: 1240},
|
||||
"zai": {tokSFloor: 35},
|
||||
"kimi": {attemptMaxS: 1200},
|
||||
"gemini": {attemptMaxS: 1200},
|
||||
}
|
||||
declared := 0
|
||||
for name, p := range m.Providers {
|
||||
got := knobs{p.Timeouts.TokSFloor, p.Timeouts.QueueSlackS, p.Timeouts.AttemptMaxS}
|
||||
if got != (knobs{}) {
|
||||
declared++
|
||||
}
|
||||
w, pinned := want[name]
|
||||
if !pinned {
|
||||
if got != (knobs{}) {
|
||||
t.Errorf("provider %q declares deadline knobs %+v that this pin does not know about — add "+
|
||||
"them here with the measurement that produced them, or the next edit loses them silently", name, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got != w {
|
||||
t.Errorf("provider %q: deadline knobs moved from %+v to %+v. These are measured numbers (a "+
|
||||
"speed floor is the p10 of this provider's own request_log) — losing one returns the "+
|
||||
"provider to the vendor default without a word anywhere", name, w, got)
|
||||
}
|
||||
}
|
||||
// The control beside the count: «every knob is where it was» and «the catalogue declares none» print
|
||||
// the same on a green test.
|
||||
if declared != len(want) {
|
||||
t.Fatalf("premise broken: %d providers declare deadline knobs, the pin names %d — the two must be "+
|
||||
"the same set or one side is not being read", declared, len(want))
|
||||
}
|
||||
t.Logf("providers in the catalogue: %d; declaring deadline knobs: %d, all pinned", len(m.Providers), declared)
|
||||
}
|
||||
|
||||
// TestACapBelowTheFloorIsRefusedAtLoad pins the one relation between the deadline knobs that no
|
||||
// per-field check can see. The derivation clamps UP to `attempt_s` and then DOWN to `attempt_max_s`, so
|
||||
// a cap below the floor wins: every call to that provider quietly gets less time than the file declares,
|
||||
// and `attempt_s`'s own doccomment — which calls it a FLOOR — stops being true. Nothing logs it; the
|
||||
// calls simply come back cut.
|
||||
func TestACapBelowTheFloorIsRefusedAtLoad(t *testing.T) {
|
||||
load := func(timeouts string) error {
|
||||
t.Helper()
|
||||
body := "prices_checked: " + time.Now().UTC().Format("2006-01-02") + `
|
||||
default_model: fake
|
||||
providers:
|
||||
p: { kind: openai, base_url: http://x, timeouts: ` + timeouts + ` }
|
||||
models:
|
||||
fake: { provider: p, price: { input_per_m: 1, output_per_m: 2 } }
|
||||
`
|
||||
_, err := LoadModels(writeTmp(t, filepath.Join(t.TempDir(), "models.yaml"), body))
|
||||
return err
|
||||
}
|
||||
// The control FIRST: a cap ABOVE the floor is an ordinary configuration and must load, or the check
|
||||
// below would be satisfied by a loader that refuses everything.
|
||||
if err := load("{ attempt_s: 240, max_attempts: 2, attempt_max_s: 1200 }"); err != nil {
|
||||
t.Fatalf("a cap above the floor is a legitimate configuration and must load: %v", err)
|
||||
}
|
||||
if err := load("{ attempt_s: 240, max_attempts: 2 }"); err != nil {
|
||||
t.Fatalf("an unset cap means «the derivation stands on its own» and must load: %v", err)
|
||||
}
|
||||
err := load("{ attempt_s: 240, max_attempts: 2, attempt_max_s: 120 }")
|
||||
if err == nil {
|
||||
t.Fatal("a cap BELOW the floor must be refused at load: the cap wins in the clamp, so every call " +
|
||||
"gets less time than the declared floor and nothing anywhere says so")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "attempt_max_s") || !strings.Contains(err.Error(), "attempt_s") {
|
||||
t.Fatalf("the refusal must name BOTH knobs, or the operator cannot see which pair is wrong: %v", err)
|
||||
}
|
||||
// And the typo guard, which is the other half of the same field's danger: a slipped digit turns a
|
||||
// ten-minute wait into an afternoon, and the only place it shows is a run that looks hung.
|
||||
if err := load("{ attempt_s: 240, max_attempts: 2, queue_slack_s: 600000 }"); err == nil {
|
||||
t.Fatal("a queue slack of a week must be refused as a typo — nobody chose to wait that long")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
343
backend/internal/llm/attemptcut.go
Normal file
343
backend/internal/llm/attemptcut.go
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http/httptrace"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// attemptcut.go: what OUR OWN deadline does to a call, and the deadline itself.
|
||||
//
|
||||
// A provider that has accepted a request generates whether or not we are still listening. When the
|
||||
// attempt deadline fires mid-generation the connection drops on our side, the provider never learns
|
||||
// it, and the call is billed all the same — so «we stopped waiting» is a MONEY event, not a transport
|
||||
// one. The transport used to lose it twice over: the body read's error went to `_`, and truncation was
|
||||
// judged by SIZE, so a body our own deadline cut short (small) was indistinguishable from a whole one
|
||||
// and went down the retryable «broken connection» branch that the same code's comment forbids for a
|
||||
// billed 2xx («each retry is a new billed 2xx»).
|
||||
//
|
||||
// THE ONE FACT COMMON TO EVERY SUCH CASE IS THAT THE REQUEST WAS DELIVERED, and it is observable
|
||||
// without a byte on the wire: net/http/httptrace reports when the request has been WRITTEN and when
|
||||
// the first response byte arrived.
|
||||
//
|
||||
// ⚠ NO SURVEYED HARNESS DRAWS THE BOUNDARY HERE, and that is a fact rather than a claim of novelty:
|
||||
// research/21 covers eleven of them and mentions httptrace nowhere (0 hits against 20 for openai-go).
|
||||
// The reason is what they are: five are stream-only and four do both (§Q6), so «did anything arrive» is
|
||||
// answered by the first event and the question never comes up; the remaining two are generic SDKs whose
|
||||
// non-streaming answer is to REFUSE a long call outright. On a non-streaming path with no refusal to
|
||||
// fall back on it has to be answered another way, and the stdlib already answers it — so the mechanism
|
||||
// is stdlib rather than ours (research/21's own rule: an industrial primary source before a home-made one).
|
||||
//
|
||||
// Those two booleans separate the three cases that a status line cannot:
|
||||
//
|
||||
// delivered, headers still pending, our deadline WroteRequest=true GotFirstResponseByte=false
|
||||
// TLS handshake hanging — NOT delivered WroteRequest=false GotFirstResponseByte=false
|
||||
// early 200 with empty lines, our deadline WroteRequest=true GotFirstResponseByte=true
|
||||
//
|
||||
// The middle row is the ONLY one where «nothing was bought» is true by construction. On DeepSeek the
|
||||
// first and third are the live ones: the vendor documents an early 200 with empty lines while a
|
||||
// request waits to be scheduled, so a 200 there means ACCEPTED, not GENERATED, and the branch that
|
||||
// reads «No 2xx ever arrived: nothing was billed» is unreachable on it — every self-cut hid under
|
||||
// decode_error instead.
|
||||
|
||||
// CutCause says WHO ended an attempt that had already been delivered. The three are dispositions,
|
||||
// not shades of one error: our own deadline is not retried and is flagged, a run the operator
|
||||
// stopped is re-done on resume at the same budget, and a broken connection gets one retry.
|
||||
type CutCause string
|
||||
|
||||
const (
|
||||
// CutBySelfDeadline: our per-attempt deadline fired while the provider was still working.
|
||||
// Retrying buys the same generation a second time — the whole point of typing this.
|
||||
CutBySelfDeadline CutCause = "attempt_timeout"
|
||||
// CutByParent: the RUN ended (stop, Ctrl-C) under a call that had already gone out. The call was
|
||||
// healthy; a human stopped it.
|
||||
CutByParent CutCause = "cancelled"
|
||||
// CutByConnection: the connection broke after delivery (unexpected EOF, an h2 stream reset) with
|
||||
// both deadlines still alive. Transport-shaped, so it is worth exactly one retry.
|
||||
CutByConnection CutCause = "connection_lost"
|
||||
)
|
||||
|
||||
// AttemptCutError is a DELIVERED request whose reply we did not receive whole.
|
||||
//
|
||||
// ⚠ It is deliberately NOT named for the timeout: two of its three causes are not one (a run somebody
|
||||
// stopped, a connection that broke), and the runner routes money, flag and resume differently for each.
|
||||
// A type named `AttemptTimeoutError` carrying `Cause: cancelled` would be a name lying about a cause,
|
||||
// which is the thing the flag vocabulary forbids outright (D39.93 п.2).
|
||||
//
|
||||
// Delivered is the money boundary and is always true on a value that reaches a caller — the
|
||||
// constructor refuses to build one otherwise, because «not delivered» is the case that must keep
|
||||
// costing nothing.
|
||||
type AttemptCutError struct {
|
||||
Provider string
|
||||
Cause CutCause
|
||||
// Delivered: the request bytes reached the provider (httptrace WroteRequest, no write error).
|
||||
Delivered bool
|
||||
// AfterHeaders: the first BYTE of a response had arrived when the call was cut (httptrace's
|
||||
// GotFirstResponseByte) — the early-200 case.
|
||||
//
|
||||
// ⚠ IT IS A BYTE, NOT A REPLY, and the distinction is the whole reason `Billable` is not this field.
|
||||
// The first byte fires for a 1xx, for a header block that never terminated, for a broken proxy's
|
||||
// garbage — none of which is a provider acknowledging anything. Read as «a response arrived» it
|
||||
// tells an operator about a reply that did not exist; read as what it is, it is the evidence that
|
||||
// something came back down the socket, and the money question is answered by `Billable`, which also
|
||||
// requires a 2xx object in hand.
|
||||
AfterHeaders bool
|
||||
// BytesRead is how much of the body we did get. It is EVIDENCE, never the criterion: judging
|
||||
// truncation by size is the defect this type replaces.
|
||||
BytesRead int
|
||||
// WhitespaceOnly says the bytes we got carry no content at all (DeepSeek's documented empty lines
|
||||
// while a request waits for scheduling). It distinguishes «the provider was still queueing» from
|
||||
// «the provider was mid-answer», which is the difference between a wait worth extending and a
|
||||
// generation worth not buying twice.
|
||||
WhitespaceOnly bool
|
||||
Elapsed time.Duration
|
||||
// Billable is the MONEY boundary, and it is deliberately NARROWER than Delivered.
|
||||
//
|
||||
// ⛔ TWO PREDICATES, BECAUSE THEY ANSWER TWO QUESTIONS. `WroteRequest` says our bytes left for the
|
||||
// peer's TCP window; it says nothing about the APPLICATION behind it — a load balancer can accept
|
||||
// while the backend never sees the request. Measured on a stopped run: 3 of 25 cancelled in-flight
|
||||
// calls settled an estimate for a request no handler ever entered. On a non-streaming wire the one
|
||||
// signal that a provider's application acknowledged the request is a 2xx response object reaching
|
||||
// US, so that is what money is drawn on. Delivery still drives RETRY — a written request is not
|
||||
// automatically re-sent — and that boundary is unchanged.
|
||||
//
|
||||
// The direction of what remains is the tolerable one: on a provider that HOLDS its headers, a
|
||||
// pre-header self-cut books zero, i.e. an under-count. On DeepSeek nothing is lost at all — its 200
|
||||
// arrives on acceptance, so every self-cut there is post-header.
|
||||
Billable bool
|
||||
// Deliveries is how many times THIS request reached the provider inside one retry chain — counted by
|
||||
// DELIVERY, not by cause, so a chain that was cut once and answered an undecodable 2xx once reports
|
||||
// two. It is almost always 1; a broken connection is worth one retry, and then the provider has been
|
||||
// asked to generate TWICE while the ledger books ONE estimate — the store cannot write two settles
|
||||
// under one key. The number is carried rather than acted on: making the gap visible is this pack's
|
||||
// business, deciding what it costs is the owner's.
|
||||
Deliveries int
|
||||
// Err is the transport error that ended the attempt.
|
||||
Err error
|
||||
// Parent is the parent context's own cause, set ONLY for CutByParent. It rides here so that
|
||||
// errors.Is(err, context.Canceled) keeps deciding the process exit code while errors.As still
|
||||
// finds this type: the exit contract and the money both need to be true of one error.
|
||||
Parent error
|
||||
}
|
||||
|
||||
func (e *AttemptCutError) Error() string {
|
||||
where := "before any response byte"
|
||||
if e.AfterHeaders {
|
||||
where = fmt.Sprintf("after %d body bytes", e.BytesRead)
|
||||
if e.WhitespaceOnly {
|
||||
where += " (whitespace only)"
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s: delivered request cut by %s %s after %s: %v",
|
||||
e.Provider, e.Cause, where, e.Elapsed.Round(time.Millisecond), e.Err)
|
||||
}
|
||||
|
||||
// Unwrap returns both truths of a cancelled attempt. Go's errors.Is/As walk every branch, so the
|
||||
// caller that asks «did the run end» and the caller that asks «what did it interrupt» both get a
|
||||
// straight answer from the same value.
|
||||
func (e *AttemptCutError) Unwrap() []error {
|
||||
if e.Parent != nil {
|
||||
return []error{e.Err, e.Parent}
|
||||
}
|
||||
return []error{e.Err}
|
||||
}
|
||||
|
||||
// deliveryTrace records the two httptrace facts the money boundary is drawn from. The callbacks run
|
||||
// on the transport's goroutine while the caller may already be reading the fields (a deadline fires
|
||||
// concurrently with a write completing), so both are atomics rather than plain bools.
|
||||
type deliveryTrace struct {
|
||||
wrote atomic.Bool
|
||||
firstByte atomic.Bool
|
||||
}
|
||||
|
||||
func (t *deliveryTrace) clientTrace() *httptrace.ClientTrace {
|
||||
return &httptrace.ClientTrace{
|
||||
// info.Err non-nil means the write itself failed — the request did NOT reach the provider,
|
||||
// and treating that as delivery would settle money for a call nobody received.
|
||||
WroteRequest: func(info httptrace.WroteRequestInfo) {
|
||||
if info.Err == nil {
|
||||
t.wrote.Store(true)
|
||||
}
|
||||
},
|
||||
GotFirstResponseByte: func() { t.firstByte.Store(true) },
|
||||
}
|
||||
}
|
||||
|
||||
// delivered reports that the request reached the provider. `answered` says the transport handed us an
|
||||
// actual 2xx response, and it is the second half of the evidence — needed, and needed CONDITIONALLY.
|
||||
//
|
||||
// ⛔ A RESPONSE BYTE IS DELIVERY EVIDENCE, BUT ONLY WHEN A REPLY ACTUALLY CAME BACK TO US. net/http does
|
||||
// not wait for the write loop before handing back a response: Request.write fires WroteRequest from a
|
||||
// deferred call on the writeLoop goroutine (net/http/request.go) while roundTrip returns as soon as the
|
||||
// response channel fires (net/http/transport.go), and golang.org/x/net/http2 has the same shape. So a
|
||||
// provider answering EARLY — precisely what DeepSeek documents doing while a request waits to be
|
||||
// scheduled — can have its 200 overtake our own «the request was written» callback whenever the body is
|
||||
// still draining. Read as «not delivered», that call books $0 and is retried: measured at three re-asks.
|
||||
//
|
||||
// ⛔⛔ AND THE OTHER READING COSTS MORE. Taking the response BYTE alone as proof, whether or not a reply
|
||||
// reached us, pays for refusals: a provider that answers 401/403/413 and resets the connection while our
|
||||
// body is still writing gives GotFirstResponseByte=true and a WRITE ERROR, so http.Client.Do returns the
|
||||
// write failure and we never see the status at all. Measured on a copy of this tree: 22 refusals in 25
|
||||
// booked a paid cut, one of them $0.80 for a request the provider declined — and the delivered-cut retry
|
||||
// sent the whole body a second time. When Do fails we hold no status line and cannot tell a refusal from
|
||||
// an early success, so only the write counts; when Do SUCCEEDS with a 2xx, the reply is proof by itself.
|
||||
func (t *deliveryTrace) delivered(answered bool) bool {
|
||||
return t.wrote.Load() || (answered && t.firstByte.Load())
|
||||
}
|
||||
func (t *deliveryTrace) afterHeaders() bool { return t.firstByte.Load() }
|
||||
|
||||
// causeOf names who ended a delivered attempt. The order is the meaning: the PARENT is asked first,
|
||||
// because a run that is ending has cancelled the attempt context too, and reading our own deadline
|
||||
// first would file every stopped run as a self-cut and flag chunks nobody's provider misbehaved on.
|
||||
func causeOf(ctx, attemptCtx context.Context) CutCause {
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
return CutByParent
|
||||
case errors.Is(attemptCtx.Err(), context.DeadlineExceeded):
|
||||
return CutBySelfDeadline
|
||||
default:
|
||||
return CutByConnection
|
||||
}
|
||||
}
|
||||
|
||||
// cutError builds the typed error for a delivered attempt, or NIL when the request never went out.
|
||||
// The «not delivered» path is the one case where nothing was bought, and it must stay exactly what it
|
||||
// was: a plain retryable transport failure that releases the reservation. Nil rather than «the error
|
||||
// unchanged» so the caller branches on a value instead of on error identity — the shape that survives
|
||||
// somebody wrapping the transport error one layer deeper.
|
||||
// `answered` is true only where a 2xx response object is in hand — see delivered.
|
||||
func (c *openAIClient) cutError(ctx, attemptCtx context.Context, tr *deliveryTrace, started time.Time, body []byte, err error, answered bool) *AttemptCutError {
|
||||
if !tr.delivered(answered) {
|
||||
return nil
|
||||
}
|
||||
cause := causeOf(ctx, attemptCtx)
|
||||
cut := &AttemptCutError{
|
||||
Provider: c.name, Cause: cause,
|
||||
Delivered: true, AfterHeaders: tr.afterHeaders(),
|
||||
Billable: answered && tr.afterHeaders(),
|
||||
BytesRead: len(body), WhitespaceOnly: len(body) == 0 || strings.TrimSpace(string(body)) == "",
|
||||
Elapsed: time.Since(started), Err: err,
|
||||
}
|
||||
if cause == CutByParent {
|
||||
cut.Parent = ctx.Err()
|
||||
}
|
||||
return cut
|
||||
}
|
||||
|
||||
// retryable is the retry half of the disposition, kept beside the causes so the two cannot drift.
|
||||
// Only a broken connection is worth another call: our own deadline firing means the provider is STILL
|
||||
// GENERATING what we just stopped listening to, and retrying buys that generation a second time — the
|
||||
// defect this file exists to remove. A cancelled run has nothing to retry into.
|
||||
func (e *AttemptCutError) retryable() bool { return e.Cause == CutByConnection }
|
||||
|
||||
// --- the deadline itself (backlog row 360 point 5, row 369) ---
|
||||
|
||||
// The industry reference for sizing a non-streaming call, and the source of the default rate:
|
||||
// anthropic-sdk-go's CalculateNonStreamingTimeout budgets 1h · max_tokens / 128 000 and REFUSES a
|
||||
// request whose expected time exceeds ten minutes («streaming is required»). Ours is the same
|
||||
// arithmetic with the rate made per-provider data and the vendor's queue wait added, because we have
|
||||
// no streaming path to fall back to and must wait instead of refusing (research/21 §Q6 + its 08.09
|
||||
// errata; openai-go's ResponseHeaderTimeout is the same idea applied to time-to-headers alone).
|
||||
//
|
||||
// It is the default because it is the one number that is not ours to invent — and it is the number
|
||||
// `attempt_s: 240` was silently standing in for: 240 s at this rate is ~8.5k tokens, the DRAFT's
|
||||
// budget, and the same 240 was carried to an editor budgeted at 16 000 with a doubling to 32 000.
|
||||
const (
|
||||
vendorHourlyTokenBudget = 128000
|
||||
vendorBudgetWindow = time.Hour
|
||||
)
|
||||
|
||||
// defaultTokensPerSecFloor is DERIVED from the pair above and never typed as a decimal: writing
|
||||
// 35.5 here would be a second carrier of a number the vendor states as 128 000 per hour, and the two
|
||||
// would drift the day either moved.
|
||||
func defaultTokensPerSecFloor() float64 {
|
||||
return float64(vendorHourlyTokenBudget) / vendorBudgetWindow.Seconds()
|
||||
}
|
||||
|
||||
// deriveDeadline is the UNCLAMPED time this profile says a call for maxTokens needs: the vendor's
|
||||
// documented wait before generation starts, plus the generation itself at the slowest speed the
|
||||
// model has been observed to hold. It is a pure function of the profile and the budget — the budget
|
||||
// the call actually carries, so an escalated attempt that doubled its max_tokens doubles its time
|
||||
// instead of inheriting the budget of an attempt that asked for half as much.
|
||||
//
|
||||
// An unset or nonsensical floor falls back to the vendor default rather than to a division by zero
|
||||
// or a zero deadline: a config that forgot the field must wait too long, never not at all.
|
||||
func (p RetryProfile) deriveDeadline(maxTokens int) time.Duration {
|
||||
floor := p.TokensPerSecFloor
|
||||
if floor <= 0 {
|
||||
floor = defaultTokensPerSecFloor()
|
||||
}
|
||||
if maxTokens < 0 {
|
||||
maxTokens = 0
|
||||
}
|
||||
return p.QueueSlack + time.Duration(float64(maxTokens)/floor*float64(time.Second))
|
||||
}
|
||||
|
||||
// DeadlineFor clamps the derivation between the configured attempt_s and attempt_max_s. It is exported
|
||||
// because it answers a question about a CONFIGURATION rather than about a call in flight — «how long
|
||||
// will one call of this size wait under this profile» — and the catalogue gate over models.yaml has to
|
||||
// ask it of a provider nobody has called yet.
|
||||
//
|
||||
// ⛔ attempt_s is the FLOOR, not the value. That is what makes this change safe to land on every
|
||||
// existing config at once: a call whose derived time is shorter than the configured deadline keeps
|
||||
// the configured one, so no provider loses a second it has today, and only calls that provably
|
||||
// could not finish get more. attempt_max_s bounds the other end — an operator's ceiling on how long
|
||||
// one call may hold a reservation — and is inert when unset.
|
||||
func (p RetryProfile) DeadlineFor(maxTokens int) time.Duration {
|
||||
d := p.deriveDeadline(maxTokens)
|
||||
if d < p.AttemptTimeout {
|
||||
d = p.AttemptTimeout
|
||||
}
|
||||
if p.AttemptMax > 0 && d > p.AttemptMax {
|
||||
d = p.AttemptMax
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// attemptDeadline is the client's own wrapper: the same clamp, plus the ONE notice an operator needs
|
||||
// when a provider is running on the vendor default. Waiting a quarter of an hour for a call is a
|
||||
// legitimate configuration — the owner ratified waiting up to ~20 minutes — but it must never be a
|
||||
// surprise, and «why is nothing happening» is the pain this line answers before it is felt.
|
||||
//
|
||||
// The condition is structural rather than a threshold in tokens: it fires exactly when the derived
|
||||
// time OVERRIDES the configured attempt_s, i.e. when this call is one the configured deadline could
|
||||
// not have covered. A provider whose calls all fit inside its own attempt_s never sees it.
|
||||
func (c *openAIClient) attemptDeadline(ctx context.Context, maxTokens int) time.Duration {
|
||||
d := c.profile.DeadlineFor(maxTokens)
|
||||
if c.profile.TokensPerSecFloor <= 0 && d > c.profile.AttemptTimeout {
|
||||
c.floorWarned.Do(func() {
|
||||
if c.log == nil {
|
||||
return
|
||||
}
|
||||
c.log.WarnContext(ctx, "no measured generation speed for this provider; the call deadline is derived from the vendor default and is longer than the configured attempt_s — set timeouts.tok_s_floor from this provider's own request_log after the first run",
|
||||
"provider", c.name, "default_tok_s", fmt.Sprintf("%.1f", defaultTokensPerSecFloor()),
|
||||
"max_tokens", maxTokens, "derived_deadline", d.String(), "attempt_s", c.profile.AttemptTimeout.String())
|
||||
})
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// cancelledDuring is what a stopped run returns: BOTH the cancellation, which decides the process exit
|
||||
// code, and whatever the attempt underneath it was — which is what decides the money.
|
||||
//
|
||||
// ⛔ IT IS errors.Join AND NOT A CHOICE BETWEEN THEM. Returning the attempt's error alone loses
|
||||
// `errors.Is(err, context.Canceled)` for every attempt error that does not happen to wrap the
|
||||
// cancellation — a stopped run would then leave with a foreign exit code. Returning the cancellation
|
||||
// alone loses `errors.As`, and with it the money for a call already on the wire, which is the defect
|
||||
// this whole file exists to remove. Join keeps both true of one value, and it does so whatever the
|
||||
// attempt's cause was: an earlier form of this guard preserved only the errors that already carried the
|
||||
// parent's own error, so a connection_lost or attempt_timeout whose run was stopped a moment later was
|
||||
// still silently reduced to a bare cancellation.
|
||||
func cancelledDuring(ctxErr, attemptErr error) error {
|
||||
if attemptErr == nil {
|
||||
return ctxErr
|
||||
}
|
||||
if errors.Is(attemptErr, ctxErr) {
|
||||
return attemptErr // it already carries both; joining would only duplicate the sentence
|
||||
}
|
||||
return errors.Join(ctxErr, attemptErr)
|
||||
}
|
||||
1330
backend/internal/llm/attemptcut_test.go
Normal file
1330
backend/internal/llm/attemptcut_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -10,8 +10,10 @@ import (
|
|||
"log/slog"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
|
|
@ -43,10 +45,23 @@ import (
|
|||
// defaults; the profile comes from models.yaml per provider (per-role
|
||||
// overrides — Phase 1).
|
||||
type RetryProfile struct {
|
||||
AttemptTimeout time.Duration // deadline for ONE HTTP attempt
|
||||
AttemptTimeout time.Duration // FLOOR for one HTTP attempt's deadline (see deadlineFor)
|
||||
MaxAttempts int
|
||||
BackoffBase time.Duration // first backoff; doubles per attempt
|
||||
BackoffCap time.Duration
|
||||
// The three fields below turn the per-attempt deadline from a constant into a function of the
|
||||
// budget the call carries (attemptcut.go). All three are optional and all three are DATA: a
|
||||
// provider the repository has never seen gets a working deadline from the vendor default and a
|
||||
// startup warning naming what to measure, with no Go edit.
|
||||
//
|
||||
// TokensPerSecFloor is the slowest generation speed this provider has been OBSERVED to hold —
|
||||
// below the p10 of its own request_log, rounded down. Unset ⇒ the vendor default.
|
||||
TokensPerSecFloor float64
|
||||
// QueueSlack is how long the VENDOR documents a request may wait before generation starts. It is
|
||||
// added whole rather than amortized: the wait is not proportional to the budget.
|
||||
QueueSlack time.Duration
|
||||
// AttemptMax bounds the derived deadline — the longest one call may hold a reservation. 0 = unbounded.
|
||||
AttemptMax time.Duration
|
||||
}
|
||||
|
||||
func (p RetryProfile) withDefaults() RetryProfile {
|
||||
|
|
@ -90,14 +105,50 @@ const (
|
|||
// reliability bonus, never a hard dependency. The local provider passes its OWN
|
||||
// no-proxy client (httpc != nil), so it is untouched — localhost needs no h2 keepalive.
|
||||
func keepAliveHTTPClient() *http.Client {
|
||||
base := http.DefaultTransport.(*http.Transport).Clone()
|
||||
if h2, err := http2.ConfigureTransports(base); err == nil && h2 != nil {
|
||||
h2.ReadIdleTimeout = h2ReadIdleTimeout
|
||||
h2.PingTimeout = h2PingTimeout
|
||||
}
|
||||
return &http.Client{Transport: base}
|
||||
c, _ := buildCloudClient()
|
||||
return c
|
||||
}
|
||||
|
||||
// buildCloudClient is the construction itself, handing back BOTH the client and the h2 transport it
|
||||
// installed the bounds on. The second return is what makes the bounds checkable at all: reading them off
|
||||
// a finished client is impossible (ConfigureTransports answers an error the second time), so a check
|
||||
// written against a client would have to re-derive them on a transport of its own and would then be
|
||||
// asserting about a transport nobody uses.
|
||||
func buildCloudClient() (*http.Client, *http2.Transport) {
|
||||
base := http.DefaultTransport.(*http.Transport).Clone()
|
||||
h2 := tuneHTTP2(base)
|
||||
return &http.Client{Transport: base, CheckRedirect: doNotFollowRedirects}, h2
|
||||
}
|
||||
|
||||
// tuneHTTP2 installs the keepalive pair and RETURNS the h2 transport it configured, so a test can read
|
||||
// what was actually set rather than re-deriving it. Asking ConfigureTransports a second time answers an
|
||||
// error and no transport, so a check written against an already-built client asserts nothing at all.
|
||||
// nil on the (unexpected) configure error: keepalive is a reliability bonus, never a hard dependency.
|
||||
func tuneHTTP2(base *http.Transport) *http2.Transport {
|
||||
h2, err := http2.ConfigureTransports(base)
|
||||
if err != nil || h2 == nil {
|
||||
return nil
|
||||
}
|
||||
h2.ReadIdleTimeout = h2ReadIdleTimeout
|
||||
h2.PingTimeout = h2PingTimeout
|
||||
return h2
|
||||
}
|
||||
|
||||
// doNotFollowRedirects stops the client at a 3xx instead of chasing it, so the redirect surfaces as the
|
||||
// terminal non-2xx it is and the operator is told the base_url is wrong.
|
||||
//
|
||||
// ⛔ IT IS A MONEY GUARD BEFORE IT IS A HYGIENE ONE. `Do` spans the WHOLE redirect chain, and the
|
||||
// delivery trace does not reset between its legs: the first leg reaching a redirector sets WroteRequest
|
||||
// (and GotFirstResponseByte) for good, so a second leg whose connect is REFUSED still looked delivered.
|
||||
// Measured through the ledger: $0.001056 booked for a `connect: connection refused` that never put a byte
|
||||
// on any wire, with AfterHeaders true and zero bytes from the target. A stale `http://` or a normalised
|
||||
// slash in base_url is enough to trigger it.
|
||||
//
|
||||
// The second reason is the one a security review would raise first: net/http drops Authorization only
|
||||
// across a host change it considers unsafe, and a provider endpoint that redirects is a misconfiguration
|
||||
// in every case — there is no shape in which silently following one is what an operator wanted.
|
||||
func doNotFollowRedirects(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
|
||||
// maxResponseBytes caps one completion body read. A translated chapter chunk
|
||||
// is ~10–50 KiB; 16 MiB leaves two orders of magnitude of headroom while
|
||||
// keeping a misbehaving endpoint from exhausting memory.
|
||||
|
|
@ -126,6 +177,13 @@ const maxRetryAfterWait = 5 * time.Minute
|
|||
func retryLoop[T any](ctx context.Context, profile RetryProfile, name string, log *slog.Logger, attempt func() (T, bool, error)) (T, error) {
|
||||
var zero T
|
||||
var lastErr error
|
||||
// ⛔ A DELIVERED CUT MUST OUTLIVE THE ATTEMPT THAT MADE IT. Only the LAST error left this loop, so a
|
||||
// chain that cut a delivered request and then met a terminal 4xx — a 400/401/403/413 on the retry,
|
||||
// entirely reachable — returned the status alone: the runner saw «the request never went out», gave
|
||||
// the reservation back and booked $0 for a generation the provider had made, leaving the position
|
||||
// with no mark either. The first cut is kept and joined onto whatever ends the chain, so the money
|
||||
// and the reason a caller finally reports are both true of one error.
|
||||
var owedCut error
|
||||
for att := 0; att < profile.MaxAttempts; att++ {
|
||||
if log != nil {
|
||||
log.DebugContext(ctx, name+" attempt start", "attempt", att+1, "max", profile.MaxAttempts,
|
||||
|
|
@ -136,11 +194,12 @@ func retryLoop[T any](ctx context.Context, profile RetryProfile, name string, lo
|
|||
return resp, nil
|
||||
}
|
||||
lastErr = err
|
||||
owedCut = moreOwed(owedCut, err)
|
||||
if ctx.Err() != nil {
|
||||
return zero, ctx.Err()
|
||||
return zero, chainError(cancelledDuring(ctx.Err(), err), owedCut)
|
||||
}
|
||||
if !retryable {
|
||||
return zero, err
|
||||
return zero, chainError(err, owedCut)
|
||||
}
|
||||
if att+1 >= profile.MaxAttempts {
|
||||
break // attempts exhausted — no retry remains, no sleep
|
||||
|
|
@ -152,11 +211,72 @@ func retryLoop[T any](ctx context.Context, profile RetryProfile, name string, lo
|
|||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return zero, ctx.Err()
|
||||
// ⛔ THE SECOND CANCELLATION EXIT, and it used to throw the evidence away where the first one
|
||||
// no longer does. A run stopped DURING a backoff has an attempt behind it that may already
|
||||
// have been delivered and billed — a cut connection, an undecodable 2xx — and returning a
|
||||
// bare ctx.Err() here left the runner with nothing to settle and the chunk with no mark at
|
||||
// all. Measured: a delivered connection_lost plus a stop inside the backoff booked $0 and
|
||||
// wrote no chunk_status row. The window is the whole sleep, up to a minute on the shipping
|
||||
// config, and it opens precisely on the runs where a provider is flapping and an operator is
|
||||
// therefore reaching for the stop.
|
||||
//
|
||||
// ⚠ THIS CLOSES THE MONEY HALF ONLY. What leaves here carries the cut with the strongest
|
||||
// money claim, which is deliberately NOT the stop when an earlier paid break outranks it — so
|
||||
// the runner's mark cannot be decided by this error's first cause. The mark asks its own
|
||||
// question (pipeline's recordCancelledStage: was the run stopped, and did ANY cut deliver),
|
||||
// and a guard that read the first cause instead left the position with no row at all.
|
||||
return zero, chainError(cancelledDuring(ctx.Err(), lastErr), owedCut)
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
return zero, fmt.Errorf("%s: exhausted %d attempts: %w", name, profile.MaxAttempts, lastErr)
|
||||
return zero, chainError(fmt.Errorf("%s: exhausted %d attempts: %w", name, profile.MaxAttempts, lastErr), owedCut)
|
||||
}
|
||||
|
||||
// ⛔ THE ERROR IS A SCALAR AND THE CHAIN IS A LIST, and every defect this pair exists to stop came from
|
||||
// that mismatch. A retry chain can deliver, and be billed for, an attempt that is NOT the attempt whose
|
||||
// error ends it: a cut, then a 503, then a stop. Whatever one error the loop finally returns is the ONLY
|
||||
// thing the caller settles from — so it must carry the cut the chain owes money for, from every exit,
|
||||
// not from the two that happened to be written with it in mind.
|
||||
//
|
||||
// moreOwed is the accumulator, and it keeps the FIRST cut deliberately rather than ranking them here.
|
||||
//
|
||||
// ⚠ RANKING BELONGS AT THE EXIT, and putting it here as well would be a guard that cannot fire. A chain
|
||||
// holds at most TWO cuts: only CutByConnection is retryable (AttemptCutError.retryable), and the
|
||||
// delivered-cut cap makes the second one terminal (`retryable && deliveredCutSeen > 1` → not retryable).
|
||||
// So whenever two cuts exist, the second one IS the error ending the chain and is in `chainError`'s hand
|
||||
// already; whenever only one exists, there is nothing to rank. A billable-beats-free branch here would
|
||||
// read like a money guard and never execute — the shape this pack has spent a shift removing.
|
||||
func moreOwed(kept, candidate error) error {
|
||||
var cc *AttemptCutError
|
||||
if kept != nil || !errors.As(candidate, &cc) {
|
||||
return kept // already holding one, or not a cut at all — a 503 owes nobody anything
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
// chainError attaches what the chain owes to whatever error ends it. It returns `final` untouched when
|
||||
// nothing is owed, when the owed cut IS what ended it (joining an error to itself prints the sentence
|
||||
// twice), or when `final` already carries a cut with a money claim at least as strong.
|
||||
//
|
||||
// ⚠ THE TEST IS THE MONEY, NOT THE TYPE. The version this replaces asked `errors.As(final, &cut)` and
|
||||
// returned early on any cut at all — true while money was drawn on `Delivered`, false the moment it was
|
||||
// drawn on `Billable`: a later FREE cut then erased an earlier PAID one and the engine booked $0.
|
||||
func chainError(final, owed error) error {
|
||||
if owed == nil || errors.Is(final, owed) {
|
||||
return final
|
||||
}
|
||||
var fc *AttemptCutError
|
||||
if errors.As(final, &fc) {
|
||||
var oc *AttemptCutError
|
||||
if errors.As(owed, &oc) && oc.Billable && !fc.Billable {
|
||||
// ⛔ THE OWED CUT GOES FIRST, and the order is the whole assertion. `errors.As` hands back the
|
||||
// FIRST match it meets walking the tree, so joining the free cut ahead of the paid one leaves
|
||||
// the caller settling from the free one — the very masking this branch exists to undo.
|
||||
return errors.Join(owed, final)
|
||||
}
|
||||
return final
|
||||
}
|
||||
return errors.Join(final, owed)
|
||||
}
|
||||
|
||||
// nextBackoff computes the wait before attempt att+1 (0-based att just failed):
|
||||
|
|
@ -212,6 +332,10 @@ type openAIClient struct {
|
|||
profile RetryProfile
|
||||
headers map[string]string // extra static headers (provider-specific), may be nil
|
||||
log *slog.Logger
|
||||
// floorWarned fires the «this provider has no measured speed» notice ONCE per client rather than
|
||||
// once per call: the fact is about the configuration, and a wave of forty chunks would otherwise
|
||||
// print it forty times and teach the operator to scroll past it.
|
||||
floorWarned sync.Once
|
||||
}
|
||||
|
||||
// newOpenAIClient builds the shared transport. httpc may be nil (default
|
||||
|
|
@ -350,8 +474,49 @@ func (c *openAIClient) complete(ctx context.Context, reqBody openAIRequest) (*op
|
|||
return nil, err
|
||||
}
|
||||
billedDecodeSeen := 0
|
||||
deliveredCutSeen := 0
|
||||
// ⛔ TWO DIFFERENT COUNTS, and they were one field. `deliveredCutSeen` is a RETRY CAP keyed on cuts.
|
||||
// `askedToGenerate` is what the operator is told — how many times this request reached the provider
|
||||
// inside one chain — and a chain delivers in more ways than by being cut: an undecodable 2xx that
|
||||
// already billed, a terminal 4xx, a 503. Counting only the cuts made the ledger line understate a
|
||||
// mixed chain while calling itself «the provider was asked N times».
|
||||
askedToGenerate := 0
|
||||
// ⛔ EVERY CUT THE CHAIN MADE IS RE-STAMPED, not only the newest. The count is a property of the
|
||||
// CHAIN, and chainError deliberately hands up an EARLIER cut when that is the one that owes money —
|
||||
// carrying a number frozen at the moment it was born. A chain that cut once and was then answered
|
||||
// two 503s delivered three times and reported one, under-stating the very gap the line exists to
|
||||
// show. The later deliveries are not cuts, so nothing in the error tree knows about them; only this
|
||||
// counter does.
|
||||
var chainCuts []*AttemptCutError
|
||||
return retryLoop(ctx, c.profile, c.name, c.log, func() (*openAIResponse, bool, error) {
|
||||
resp, retryable, err := c.attempt(ctx, payload)
|
||||
resp, retryable, err := c.attempt(ctx, payload, reqBody.maxTokens)
|
||||
if deliveredAttempt(resp, err) {
|
||||
askedToGenerate++
|
||||
}
|
||||
defer func() {
|
||||
for _, cc := range chainCuts {
|
||||
cc.Deliveries = askedToGenerate
|
||||
}
|
||||
}()
|
||||
// Cap a DELIVERED cut's re-calls at ONE, for the same reason and by the same shape as the
|
||||
// billed-decode cap above — but keyed on DELIVERY rather than on a 2xx. That is the whole
|
||||
// correction: on a provider that answers 200 while the request is still queued (DeepSeek
|
||||
// documents exactly that), «did a 2xx arrive» says nothing about whether a generation was
|
||||
// bought, while «did the request go out» says it exactly. A broken connection after delivery
|
||||
// is worth one more call; a second is a dead provider, not a flaky socket.
|
||||
if err != nil {
|
||||
var cut *AttemptCutError
|
||||
if errors.As(err, &cut) {
|
||||
deliveredCutSeen++
|
||||
// The provider has now been asked to generate this many times, and the caller is told:
|
||||
// one settle will cover all of them, because the store writes spend only through a
|
||||
// checkpoint and they share one key.
|
||||
chainCuts = append(chainCuts, cut)
|
||||
if retryable && deliveredCutSeen > 1 {
|
||||
return resp, false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cap billed-decode re-bills at ONE (research/21 §1.18в): an undecodable 2xx
|
||||
// has ALREADY billed, so re-running it under the full MaxAttempts turns a
|
||||
// provider emitting 2xx garbage into a paid retry STORM (grok-build's
|
||||
|
|
@ -371,15 +536,44 @@ func (c *openAIClient) complete(ctx context.Context, reqBody openAIRequest) (*op
|
|||
})
|
||||
}
|
||||
|
||||
// deliveredAttempt reports whether ONE attempt's request reached the provider — the question the
|
||||
// operator's «asked N times» line answers, and a different question from «did it end in a cut».
|
||||
//
|
||||
// A status of any kind is delivery by definition: the peer had to read the request to answer it. So is a
|
||||
// 2xx whose body would not parse — that one has already billed. A cut says so itself. What is NOT a
|
||||
// delivery is everything that failed before the bytes left: a refused connect, a DNS failure, a write
|
||||
// that died mid-body.
|
||||
func deliveredAttempt(resp *openAIResponse, err error) bool {
|
||||
if err == nil {
|
||||
return resp != nil
|
||||
}
|
||||
var cut *AttemptCutError
|
||||
if errors.As(err, &cut) {
|
||||
return cut.Delivered
|
||||
}
|
||||
var hse *HTTPStatusError
|
||||
var bde *BilledDecodeError
|
||||
return errors.As(err, &hse) || errors.As(err, &bde)
|
||||
}
|
||||
|
||||
// attempt performs one HTTP call. Returns retryable=true for 429/5xx and
|
||||
// network errors, false for other non-2xx (terminal 4xx). The per-attempt
|
||||
// deadline bounds a single hung connection; the overall per-request deadline
|
||||
// (set by the caller via ctx) bounds the whole retry loop.
|
||||
func (c *openAIClient) attempt(ctx context.Context, payload []byte) (*openAIResponse, bool, error) {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, c.profile.AttemptTimeout)
|
||||
// deadline is derived from THIS call's output budget (attemptcut.go); the
|
||||
// overall per-request deadline (set by the caller via ctx) bounds the whole retry loop.
|
||||
//
|
||||
// maxTokens is the budget the body already carries. It is passed rather than re-parsed so the
|
||||
// deadline and the request can never describe different calls.
|
||||
func (c *openAIClient) attempt(ctx context.Context, payload []byte, maxTokens int) (*openAIResponse, bool, error) {
|
||||
deadline := c.attemptDeadline(ctx, maxTokens)
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, deadline)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, c.base+"/chat/completions", bytes.NewReader(payload))
|
||||
// The delivery facts are collected by the transport itself: a request is DELIVERED once its bytes
|
||||
// are written, which is knowable before any reply exists and is the boundary the money is drawn on.
|
||||
var tr deliveryTrace
|
||||
started := time.Now()
|
||||
req, err := http.NewRequestWithContext(httptrace.WithClientTrace(attemptCtx, tr.clientTrace()),
|
||||
http.MethodPost, c.base+"/chat/completions", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
|
@ -395,18 +589,26 @@ func (c *openAIClient) attempt(ctx context.Context, payload []byte) (*openAIResp
|
|||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
// Network error / timeout — retryable (unless the parent ctx is done). A
|
||||
// per-attempt deadline is annotated with the configured timeout: the bare
|
||||
// «context deadline exceeded» doesn't tell the operator WHOSE deadline it is —
|
||||
// the attempt timeout (cured by timeouts.attempt_s) or the whole run cancelled.
|
||||
// A DELIVERED request that never answered: the provider has it and is (or was) working, so
|
||||
// this is a money event and carries its cause. An UNDELIVERED one stays exactly what it was —
|
||||
// a plain retryable transport failure whose reservation is released, the one case where
|
||||
// «nothing was bought» is true by construction.
|
||||
if cut := c.cutError(ctx, attemptCtx, &tr, started, nil, err, false); cut != nil {
|
||||
return nil, cut.retryable(), cut
|
||||
}
|
||||
// The bare «context deadline exceeded» doesn't tell the operator WHOSE deadline it is —
|
||||
// this call's own (derived from its budget) or the whole run cancelled.
|
||||
if errors.Is(attemptCtx.Err(), context.DeadlineExceeded) && ctx.Err() == nil {
|
||||
err = fmt.Errorf("attempt timed out after %s (timeouts.attempt_s): %w", c.profile.AttemptTimeout, err)
|
||||
err = fmt.Errorf("attempt timed out after %s (derived from max_tokens=%d; timeouts.attempt_s is its floor) before the request was delivered: %w", deadline, maxTokens, err)
|
||||
}
|
||||
return nil, ctx.Err() == nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Read one byte past the limit to DISTINGUISH truncation from a whole body.
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
|
||||
// Read one byte past the limit to DISTINGUISH truncation from a whole body — for the >16 MiB case
|
||||
// alone. Whether the read COMPLETED is a separate question with a separate answer, and dropping
|
||||
// readErr here is what made a body our own deadline cut short (small) indistinguishable from a
|
||||
// whole one, so it went down the retryable branch and bought the same generation twice.
|
||||
data, readErr := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
|
||||
truncated := len(data) > maxResponseBytes
|
||||
if truncated {
|
||||
data = data[:maxResponseBytes]
|
||||
|
|
@ -414,15 +616,35 @@ func (c *openAIClient) attempt(ctx context.Context, payload []byte) (*openAIResp
|
|||
|
||||
obs.LogLLMExchange(ctx, c.log, c.name, payload, resp.StatusCode, data)
|
||||
|
||||
// ⛔ THE STATUS LINE IS ASKED BEFORE THE READ ERROR, and the order is the money. A non-2xx says the
|
||||
// provider refused or failed — nothing was generated and nothing is owed — so a body cut short
|
||||
// under it is a detail of a failure, not a purchase. Reading them the other way round would settle
|
||||
// an estimate for every 4xx whose tiny body happened to land on the deadline. The partial body is
|
||||
// still what the retry/terminal split reads (a truncated marker just fails to match and the status
|
||||
// stays retryable, the conservative direction), and the read error rides in the message so a
|
||||
// misclassified 429 leaves a trace instead of none.
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
retryable := retryableStatus(resp.StatusCode, data)
|
||||
e := &HTTPStatusError{Provider: c.name, Status: resp.StatusCode, Body: snippet(data)}
|
||||
if readErr != nil {
|
||||
e.Body = snippet(data) + fmt.Sprintf(" [body read incomplete: %v]", readErr)
|
||||
}
|
||||
if retryable {
|
||||
e.RetryAfter = parseRetryAfter(resp.Header) // only a retryable status will honour it
|
||||
}
|
||||
return nil, retryable, e
|
||||
}
|
||||
|
||||
if readErr != nil {
|
||||
// A 2xx whose body we did not receive whole. Headers had arrived, so the request was written
|
||||
// by definition — but ask the trace rather than assume it, and let an undelivered
|
||||
// impossibility fall through to the old shape instead of settling money on a deduction.
|
||||
if cut := c.cutError(ctx, attemptCtx, &tr, started, data, readErr, true); cut != nil {
|
||||
return nil, cut.retryable(), cut
|
||||
}
|
||||
return nil, ctx.Err() == nil, readErr
|
||||
}
|
||||
|
||||
var out openAIResponse
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
// A 2xx with an unreadable body: the provider has ALREADY charged. We type it
|
||||
|
|
|
|||
|
|
@ -73,9 +73,16 @@ func NewAnthropicClient(cfg AnthropicConfig, logger *slog.Logger) LLMClient {
|
|||
base = "https://api.anthropic.com"
|
||||
}
|
||||
return &anthropicClient{
|
||||
base: base,
|
||||
key: cfg.APIKey,
|
||||
http: &http.Client{},
|
||||
base: base,
|
||||
key: cfg.APIKey,
|
||||
// ⚠ THE ONE THING THIS DEPRECATED ADAPTER GETS FROM THE CUT-CALL PACK, and it is here because it
|
||||
// is a CREDENTIAL leak rather than adapter work: this client sends `x-api-key`, and net/http
|
||||
// strips only Authorization/Cookie/WWW-Authenticate across a host change — so a 3xx from a
|
||||
// mistyped base_url carried the key to a host nobody chose. Measured: the redirect target
|
||||
// received the key and the call returned nil error. The rest of the pack's money boundary is
|
||||
// deliberately NOT here (this adapter has no delivery trace and a constant deadline); a key
|
||||
// walking off is not a scope question.
|
||||
http: &http.Client{CheckRedirect: doNotFollowRedirects},
|
||||
profile: cfg.Profile.withDefaults(),
|
||||
cacheTTL: cfg.CacheTTL,
|
||||
log: logger,
|
||||
|
|
|
|||
|
|
@ -45,8 +45,16 @@ type LocalConfig struct {
|
|||
|
||||
// NoProxyClient is an http.Client that bypasses any environment proxy.
|
||||
// Exported so the failover prober uses the same transport discipline.
|
||||
//
|
||||
// ⚠ IT REFUSES REDIRECTS FOR THE SAME REASON THE CLOUD CLIENT DOES, and it needed saying separately:
|
||||
// the guard first went only on keepAliveHTTPClient, whose comment says the local provider «passes its
|
||||
// OWN no-proxy client, so it is untouched» — true of keepalive, and read as permission for redirects
|
||||
// too. `Do` spans a whole redirect chain without resetting the delivery trace, so a leg whose connect
|
||||
// is refused still looks delivered. On the local stand the money is $0 today only because the local
|
||||
// model is priced at zero; the BEHAVIOUR is wrong either way — a mistyped base_url would reach an
|
||||
// operator as a flapping socket instead of as the 3xx it is.
|
||||
func NoProxyClient() *http.Client {
|
||||
return &http.Client{Transport: &http.Transport{Proxy: nil}}
|
||||
return &http.Client{Transport: &http.Transport{Proxy: nil}, CheckRedirect: doNotFollowRedirects}
|
||||
}
|
||||
|
||||
type localClient struct {
|
||||
|
|
|
|||
1193
backend/internal/pipeline/burnedpregates_test.go
Normal file
1193
backend/internal/pipeline/burnedpregates_test.go
Normal file
File diff suppressed because it is too large
Load diff
255
backend/internal/pipeline/cutcall.go
Normal file
255
backend/internal/pipeline/cutcall.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"textmachine/backend/internal/chunk"
|
||||
"textmachine/backend/internal/config"
|
||||
"textmachine/backend/internal/llm"
|
||||
"textmachine/backend/internal/store"
|
||||
)
|
||||
|
||||
// cutcall.go: the money and the disposition of a call THE ENGINE ITSELF cut short (backlog row 360;
|
||||
// the owner's word on the amount is D39.230 п.1). The transport half — how a cut is detected and told
|
||||
// apart from an undelivered request — lives in internal/llm/attemptcut.go; this file decides what it
|
||||
// COSTS and what happens to the chunk.
|
||||
//
|
||||
// A delivered request is generated whether or not we are still on the line. Three things end our side
|
||||
// of it, they share the money, and they differ in everything else:
|
||||
//
|
||||
// attempt_timeout our deadline fired mid-generation. 1 call, flagged, NOT retried. Resume: 0 calls.
|
||||
// cancelled a person stopped the run. 1 call, marked `cancelled`. Resume: 1 call, SAME budget.
|
||||
// connection_lost the socket broke after delivery. 2 calls, then an infra pause. Resume: 1 call, SAME budget.
|
||||
//
|
||||
// Until this file existed all three arrived at a branch commented «No 2xx ever arrived: nothing was
|
||||
// billed», which released the reservation and booked ZERO. On the provider that ships today that
|
||||
// comment is false by construction: DeepSeek answers 200 with empty lines while a request waits to be
|
||||
// scheduled, so a status line says nothing about whether a generation was bought.
|
||||
|
||||
// cutCall is the identity of the call being settled — the same tuple runAttempt already holds, passed
|
||||
// as one value because thirteen positional arguments is how a chapter index and a chunk index end up
|
||||
// swapped without the compiler noticing.
|
||||
type cutCall struct {
|
||||
stage config.Stage
|
||||
chunk chunk.Chunk
|
||||
job *store.Job
|
||||
model string
|
||||
reqHash string
|
||||
resv store.Reservation
|
||||
estimate float64
|
||||
attempt int
|
||||
escalation bool
|
||||
}
|
||||
|
||||
// cancelledPosition is the chunk×stage a stopped run was working on. It is a struct for the reason
|
||||
// cutCall is: `snapID` and `contentHash` are adjacent strings, and a swap between them compiles,
|
||||
// writes a row addressed to nothing, and is found by nobody.
|
||||
type cancelledPosition struct {
|
||||
stage config.Stage
|
||||
chunk chunk.Chunk
|
||||
snapshotID string
|
||||
contentHash string
|
||||
cumCostUSD float64
|
||||
attempts int
|
||||
}
|
||||
|
||||
// settleUSDForCutCall is the ONE place that answers «what does a call we cut short cost».
|
||||
//
|
||||
// The answer is the reservation's own estimate (D39.230 п.1). Nobody knows what the provider actually
|
||||
// billed, and whether it bills a cut call at all is deliberately OPEN — the paid balance probe that
|
||||
// would answer it was declined — so the estimate is an upper bound under the assumption that the
|
||||
// vendor charges for what it generated. Booking zero understates the book against its own ceiling and
|
||||
// hands the platform a margin it cannot measure.
|
||||
//
|
||||
// It is a function and not a YAML field because a money policy behind a config knob can be changed by
|
||||
// an operator who is not deciding a money policy, silently, between two runs of one book — leaving the
|
||||
// ledger with two answers to one question and nothing on the row saying which.
|
||||
func settleUSDForCutCall(estimate float64) float64 { return estimate }
|
||||
|
||||
// settleCutCall books the money for a delivered call the engine cut short, records the row, and
|
||||
// returns the disposition that cause deserves.
|
||||
//
|
||||
// The money is written FIRST, before anything decides what happens to the chunk: the provider's side
|
||||
// of the transaction already happened, and a process that dies between here and the verdict must leave
|
||||
// the spend recorded. The checkpoint is what makes booking it possible at all — the store cannot write
|
||||
// spend without one — and for two of the three causes it is also what stops the next run from buying
|
||||
// the same call again.
|
||||
func (r *Runner) settleCutCall(ctx context.Context, c cutCall, cut *llm.AttemptCutError, err error, att stageAttempt) (stageAttempt, error) {
|
||||
// ⛔ THE MONEY IS DRAWN ON THE PROVIDER'S OWN ACKNOWLEDGEMENT, not on our write. A request whose
|
||||
// bytes entered the peer's TCP window may never reach the application behind it — a load balancer
|
||||
// accepts, the backend never sees it — and settling for that charges a reader for a call nobody
|
||||
// ran, which is the one direction the canon forbids (D39.196 п.2а). A 2xx object in our hands is
|
||||
// the acknowledgement; without it the same checkpoint is written for ZERO.
|
||||
//
|
||||
// The row is identical either way: same key, same class, same flag, same resume. Only the number
|
||||
// and the `estimated` mark move, so an operator sees the class and the reader pays only for what a
|
||||
// provider confirmed taking.
|
||||
cost := 0.0
|
||||
if cut.Billable {
|
||||
cost = settleUSDForCutCall(c.estimate)
|
||||
}
|
||||
finish := string(cut.Cause)
|
||||
if serr := r.Store.SettleWithCheckpoint(c.resv, cost, store.Checkpoint{
|
||||
RequestHash: c.reqHash, JobID: c.job.ID, ChunkIdx: c.chunk.ChunkIdx, Attempt: c.attempt,
|
||||
Stage: c.stage.Name, Role: c.stage.Role, ModelRequested: c.model, ModelActual: c.model,
|
||||
ResponseText: "", UsageJSON: "{}", CostUSD: cost, FinishReason: finish,
|
||||
Escalation: c.escalation,
|
||||
}, r.events.spendLine()); serr != nil {
|
||||
// The rule of every settle on this path: money state falling behind reality is a loud infra
|
||||
// fault, never something to continue on top of.
|
||||
return att, fmt.Errorf("pipeline: settle after a %s cut a delivered call: %w", finish, serr)
|
||||
}
|
||||
r.events.flush()
|
||||
att.finish = finish
|
||||
// `att` arrives carrying the money of any burned keys runAttempt walked over, and that money is
|
||||
// this chunk's just as much as this call's — dropping it would under-state a position that was cut
|
||||
// twice. `runCost` takes only what THIS run bought.
|
||||
att.cumCost, att.runCost = att.cumCost+cost, cost
|
||||
|
||||
rl := r.baseRequestLog(c.stage, c.chunk, c.model, c.reqHash)
|
||||
rl.CostUSD, rl.LatencyMS, rl.FinishReason = cost, att.latency, finish
|
||||
rl.Degraded, rl.Err, rl.OK = finish, err.Error(), false
|
||||
if cut.Deliveries > 1 {
|
||||
// ⚠ THE LEDGER IS SHORT BY DESIGN HERE, AND THE ROW SAYS SO. The provider was asked to generate
|
||||
// more than once inside one retry chain, and the store books spend only through a checkpoint —
|
||||
// they share one key, so one settle covers all of them. Under-counting is the ratified
|
||||
// direction (D39.196 п.2а: the deploy absorbs it, the user's balance is never overstated), so
|
||||
// this pack does NOT quietly multiply the charge; it makes the gap readable instead, because a
|
||||
// silent under-count is what row 360 was opened about.
|
||||
// ⛔ THE NOTE GOES FIRST, and that is not cosmetics. The column an operator reads is bounded —
|
||||
// `errTail` keeps 120 bytes — and the transport error in front of it is routinely longer than
|
||||
// that, so a note appended to the end reached nobody, ever. It also says what was ACTUALLY
|
||||
// booked: on a cut the provider never acknowledged, the answer is nothing, and a line claiming
|
||||
// «one estimate is booked» beside a $0 row is a sentence that has to be disbelieved to be used.
|
||||
rl.Err = cutErrLine(err, cut.Deliveries, cost)
|
||||
}
|
||||
// «Never write a silent $0 usage; estimate and FLAG it» is the one discipline every surveyed
|
||||
// harness converges on (research/21 §5.3: crush `EstimatedUsage`, goose `CostSource`). The flag is
|
||||
// what the operator's legend, the machine-readable pair beside committed_usd and the platform's
|
||||
// «≥ X, up to Y» all read.
|
||||
rl.Estimated, rl.EstTokens = cost > 0, r.estOutTokens(c.chunk.Text)
|
||||
r.Store.LogRequest(ctx, r.Log, rl)
|
||||
|
||||
r.Log.WarnContext(ctx, "we cut a delivered call; the reservation estimate is charged as an ESTIMATE only when the provider had acknowledged it with a reply",
|
||||
"stage", c.stage.Name, "chapter", c.chunk.Chapter, "chunk", c.chunk.ChunkIdx, "attempt", c.attempt,
|
||||
"cause", finish, "after_headers", cut.AfterHeaders, "bytes_read", cut.BytesRead,
|
||||
"whitespace_only", cut.WhitespaceOnly, "elapsed", cut.Elapsed.Round(time.Millisecond).String(),
|
||||
"deliveries", cut.Deliveries, "estimate_usd", fmt.Sprintf("%.6f", cost))
|
||||
|
||||
if cut.Cause == llm.CutBySelfDeadline {
|
||||
// Our deadline, the run is healthy: a disposition, not an infra failure. Nothing retries it —
|
||||
// a retry asks the provider to generate, and bill, the very thing it is generating right now.
|
||||
att.cls = classification{FlagAttemptTimeout, cut.Error()}
|
||||
r.setJobStatus(ctx, c.job.ID, "done")
|
||||
return att, nil
|
||||
}
|
||||
// A stopped run and a dead socket both end this stage. The money above is recorded either way; the
|
||||
// error travels so the run pauses instead of walking the rest of the book against a dead provider.
|
||||
r.setJobStatus(ctx, c.job.ID, "failed")
|
||||
return att, fmt.Errorf("pipeline: stage %s call (ch%d/chunk%d, model %s): %w",
|
||||
c.stage.Name, c.chunk.Chapter, c.chunk.ChunkIdx, c.model, err)
|
||||
}
|
||||
|
||||
// cutErrLine is the sentence an operator reads when one retry chain asked the provider to generate more
|
||||
// than once. It is a named function rather than a Sprintf at the call site because both of its
|
||||
// properties are load-bearing and neither is visible from there.
|
||||
//
|
||||
// ⛔ THE NOTE COMES FIRST. The column that prints this is bounded (cmd/tmctl's errTail keeps 120 bytes)
|
||||
// and the transport error is routinely longer, so a note appended at the end reached nobody — while
|
||||
// being the only place the gap between what the provider generated and what the ledger booked is
|
||||
// visible at all.
|
||||
//
|
||||
// ⛔ AND IT SAYS WHAT WAS ACTUALLY BOOKED. On a cut the provider never acknowledged, the answer is
|
||||
// nothing; «one estimate is booked» printed beside a $0 row is a sentence a reader has to disbelieve
|
||||
// before they can use it.
|
||||
func cutErrLine(err error, deliveries int, cost float64) string {
|
||||
booked := "ONE estimate is booked for all of them"
|
||||
if cost <= 0 {
|
||||
booked = "NOTHING is booked: the provider acknowledged none of them"
|
||||
}
|
||||
return fmt.Sprintf("[the provider was asked %d times; %s] %s", deliveries, booked, err.Error())
|
||||
}
|
||||
|
||||
// recordCancelledStage marks a position whose call a HUMAN stopped, so the stop leaves no unexplained
|
||||
// gap. It never changes the outcome it is called on: the run is ending, and this says what it was
|
||||
// doing when it did.
|
||||
//
|
||||
// The mark carries its own reason. The call was healthy — the stop button ended it — so
|
||||
// `flagged(cancelled)` reads as «stopped; the resume re-does it», while a shared flag would send an
|
||||
// operator hunting for a defect that is not there and no mark at all would let the chapter export a
|
||||
// hole nobody knows about. A flag lying about its cause is forbidden in its own right (D39.93 п.2).
|
||||
//
|
||||
// A write failure is logged, not returned: the caller is already returning the error that stopped the
|
||||
// run, and replacing it with a bookkeeping failure would hide why the run stopped.
|
||||
func (r *Runner) recordCancelledStage(ctx context.Context, p cancelledPosition, err error) {
|
||||
// ⛔ THE STOP IS ASKED OF THE ERROR AS A WHOLE, and the delivery of ANY cut in it — not of whichever
|
||||
// cut `errors.As` happens to reach first. A retry chain hands up the cut with the strongest money
|
||||
// claim (llm's chainError), which is deliberately the EARLIER one when a later free cut would mask
|
||||
// it — so a run stopped over an attempt behind a paid `connection_lost` arrives here carrying that
|
||||
// cause, and a guard reading only the first cut's cause decided «this is not a stop» and returned in
|
||||
// silence. The money was settled, the position got no row, and the export showed an unexplained gap:
|
||||
// the hole §4.2 forbids «at any moment».
|
||||
// ⚠ «A cut» IS «a delivered cut», by construction and in one place: cutError returns NIL when the
|
||||
// request never went out, and the single construction of the type sets Delivered true. So one
|
||||
// errors.As answers both halves, and a walk over the error tree looking for a delivered one would be
|
||||
// asking a question that cannot come back different.
|
||||
var cut *llm.AttemptCutError
|
||||
if !errors.Is(err, context.Canceled) || !errors.As(err, &cut) {
|
||||
return
|
||||
}
|
||||
if uerr := r.Store.UpsertChunkStatus(store.ChunkStatus{
|
||||
BookID: r.Book.BookID, Chapter: p.chunk.Chapter, ChunkIdx: p.chunk.ChunkIdx, Stage: p.stage.Name,
|
||||
SnapshotID: p.snapshotID, ContentHash: p.contentHash,
|
||||
Disposition: string(DispFlagged), FlagReason: string(FlagCancelled),
|
||||
Attempts: p.attempts, FinalHash: "", CostUSD: p.cumCostUSD,
|
||||
Detail: "the run was stopped while this call was in flight; it was paid for at the reservation estimate and the resume re-does it on the same budget",
|
||||
}); uerr != nil {
|
||||
r.Log.ErrorContext(ctx, "could not mark the stopped position; its money is recorded but the chunk will read as never started",
|
||||
"stage", p.stage.Name, "chapter", p.chunk.Chapter, "chunk", p.chunk.ChunkIdx, "err", uerr)
|
||||
return
|
||||
}
|
||||
r.Log.WarnContext(ctx, "the run was stopped over a call that had already gone out; the position is marked cancelled and the resume re-does it on the same budget",
|
||||
"stage", p.stage.Name, "chapter", p.chunk.Chapter, "chunk", p.chunk.ChunkIdx, "cost_usd", fmt.Sprintf("%.6f", p.cumCostUSD))
|
||||
}
|
||||
|
||||
// paidAfterBurns answers «was this call already paid for AND answered», asked THE WAY THE FUNNEL WILL
|
||||
// ASK IT. It is the single definition of that question for every pre-gate that decides money before a
|
||||
// call, and it exists because asking it any other way has now been wrong in both directions.
|
||||
//
|
||||
// ⛔ A FIXED ATTEMPT INDEX CANNOT ANSWER IT. runAttempt does not stop at a burned key: it walks to the
|
||||
// next index at the SAME budget and buys there, so after a stopped run a position reads «attempt 0
|
||||
// burned, attempt 1 paid and answered». A probe that looks only at the starting index sees the burn and
|
||||
// says «not paid» — and the caller, finding no budget left, discards a translation that was already
|
||||
// bought. A probe that ignores burns says «paid» — and the caller skips its budget check while the
|
||||
// funnel goes and buys the work again. Both were measured, one after the other, on this very code.
|
||||
//
|
||||
// So the probe walks exactly as the funnel walks (stagerun.go, the burn loop) and answers about the key
|
||||
// the funnel will actually use. Two questions asked one way cannot disagree; that is the whole point of
|
||||
// this function existing rather than three call sites each getting the walk right.
|
||||
func (r *Runner) paidAfterBurns(st config.Stage, model, snapID string, ch chunk.Chunk, attempt, maxTokens int, msgs []llm.Message) (bool, error) {
|
||||
for {
|
||||
cp, err := r.Store.GetCheckpoint(RequestHash(r.attemptRequest(st, model, snapID, ch, attempt, maxTokens, msgs)))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if cp == nil {
|
||||
return false, nil // the walk ran out of keys: nothing here is paid for
|
||||
}
|
||||
if !burnedByCut(cp) {
|
||||
return true, nil // an answer — this is what the funnel would serve for $0
|
||||
}
|
||||
attempt++
|
||||
}
|
||||
}
|
||||
|
||||
// resolvedForResume says a stored disposition is an ANSWER the resume may serve without calling
|
||||
// anybody. Everything terminal is; `cancelled` is the one row that is not, because it records a stop
|
||||
// rather than a verdict and the work behind it was never done. Reading it as terminal degenerates the
|
||||
// whole construction into its opposite — never re-doing anything that was interrupted — which is the
|
||||
// failure mode this design is most at risk of, since it would look perfectly green.
|
||||
func resolvedForResume(cs *store.ChunkStatus) bool {
|
||||
return FlagReason(cs.FlagReason) != FlagCancelled
|
||||
}
|
||||
1460
backend/internal/pipeline/cutcall_test.go
Normal file
1460
backend/internal/pipeline/cutcall_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,7 @@ import (
|
|||
"textmachine/backend/internal/lang"
|
||||
"textmachine/backend/internal/langscreen"
|
||||
"textmachine/backend/internal/llm"
|
||||
"textmachine/backend/internal/store"
|
||||
)
|
||||
|
||||
// disposition.go: the per-chunk×stage verdict machinery of the Milestone-2 runner
|
||||
|
|
@ -78,6 +79,27 @@ const (
|
|||
// Emitted by the runner's billed-decode path.
|
||||
FlagDecodeError FlagReason = "decode_error" // 2xx with an unreadable body — billed, conservatively settled, flagged
|
||||
|
||||
// FlagAttemptTimeout and FlagCancelled are the two verdicts of a call THE ENGINE ITSELF cut short
|
||||
// (backlog row 360). Both name their cause exactly, and they are two constants rather than one
|
||||
// because the disposition differs on every axis that matters:
|
||||
//
|
||||
// - attempt_timeout — OUR deadline fired while the provider was generating. Deterministic on the
|
||||
// same budget, so it is NOT retryable and NOT escalatable: a same-model retry re-buys the same
|
||||
// generation, which is the defect row 360 names. The remedy is a bigger deadline (now derived
|
||||
// from the budget) or a redrive, not another call inside this run.
|
||||
// - cancelled — a HUMAN stopped the run over a call that was already on the wire. Nothing was
|
||||
// wrong with it, so the chunk is not «needs a person»; it is «stopped, resume will re-do it».
|
||||
// It still marks the position: a hole with no mark at all would export as an unexplained gap.
|
||||
//
|
||||
// ⚠ The vocabulary is CONVERTED from the transport's causes, not re-typed beside them: one carrier
|
||||
// for the three names the wire, the checkpoint, the flag and the operator all read.
|
||||
FlagAttemptTimeout FlagReason = FlagReason(llm.CutBySelfDeadline)
|
||||
FlagCancelled FlagReason = FlagReason(llm.CutByParent)
|
||||
// ⚠ A LOST CONNECTION HAS NO FLAG, and that is a statement about the mechanism rather than an
|
||||
// omission. It is an infra pause, not a chunk that needs a human, and the checkpoint it leaves
|
||||
// records money with no result — which burnedByCut catches BEFORE anything classifies it, so no
|
||||
// replay of that row ever reaches a disposition. The guard is that predicate, not a name.
|
||||
|
||||
// Reserved for later steps — DEFINED for contract stability, NOT emitted by
|
||||
// Milestone 2. coverage_fail / excision_suspect are verdicts of the configurable
|
||||
// coverage gate (step 6); hard_block / upstream_not_ok are for HTTP-level
|
||||
|
|
@ -144,6 +166,44 @@ var classifierVersion = "classify-v3-refusal+srcscript-echo015+" + langscreen.Ve
|
|||
// path assigned — live and resume must agree (determinism of the resolve).
|
||||
const decodeErrorFinish = "decode_error"
|
||||
|
||||
// The finish_reason a cut call's checkpoint carries. They come from the transport's own cause
|
||||
// vocabulary (llm.CutCause) so that the wire fact, the stored row and the flag cannot be named three
|
||||
// different things by three files.
|
||||
//
|
||||
// ⛔ TWO OF THEM ARE NOT RESULTS, and that is the load-bearing distinction. attemptTimeoutFinish IS a
|
||||
// verdict — classify resolves it below and a resume serves it without calling anybody, which is the
|
||||
// point: we already paid for that generation and will not buy it again. The other two record MONEY for
|
||||
// a call that has no outcome at all, so replaying them would hand the stage an empty answer it never
|
||||
// received; runAttempt burns them instead (burnedByCut) and the loop re-asks at the SAME budget.
|
||||
const (
|
||||
attemptTimeoutFinish = string(llm.CutBySelfDeadline)
|
||||
cancelledFinish = string(llm.CutByParent)
|
||||
connectionLostFinish = string(llm.CutByConnection)
|
||||
)
|
||||
|
||||
// burnedByCut says a checkpoint records what a call COST without recording what it produced. Such a
|
||||
// row is money only: it must never be served as a result, and its attempt index is spent — the next
|
||||
// index re-asks the same budget under a fresh request_hash.
|
||||
//
|
||||
// This is the whole reason the money can be booked at all. The store has exactly three money verbs
|
||||
// and none of them writes spend without a checkpoint, while the checkpoint IS the resume key
|
||||
// (`ON CONFLICT (request_hash) DO NOTHING`) — so «pay and re-do under the same key» is impossible by
|
||||
// construction. Separating the ATTEMPT INDEX from the count of budget DOUBLINGS (runStage) is what
|
||||
// dissolves that: the re-done call is a different key at the same budget, its reserve→settle is
|
||||
// fresh, and `committed == sum(checkpoints)` never wobbles.
|
||||
//
|
||||
// ⚠ THE TEXT IS PART OF THE TEST, and the finish_reason alone was not enough. That string shares a
|
||||
// namespace with whatever a vendor decides to print — the adapter already normalises invented values
|
||||
// like «sensitive» — so a provider answering 200 with a real translation and finish_reason
|
||||
// «cancelled» would have had its answer thrown away and re-bought. A genuine burn is written here and
|
||||
// is always textless (cutcall.go), so asking for both costs nothing and closes the namespace.
|
||||
func burnedByCut(cp *store.Checkpoint) bool {
|
||||
if cp.ResponseText != "" {
|
||||
return false // a reply with content is an answer, whatever it calls its finish reason
|
||||
}
|
||||
return cp.FinishReason == cancelledFinish || cp.FinishReason == connectionLostFinish
|
||||
}
|
||||
|
||||
// retryable reports whether a flag may be re-attacked on the SAME model along the
|
||||
// attempt axis. Only length/empty: both are budget symptoms a bigger max_tokens
|
||||
// can cure. Everything else is deterministic (refusal/filter/echo/loop/decode) —
|
||||
|
|
@ -242,6 +302,23 @@ func classify(in classifyInput) classification {
|
|||
switch finish {
|
||||
case decodeErrorFinish:
|
||||
return classification{FlagDecodeError, "billed 2xx with an unreadable body"}
|
||||
case attemptTimeoutFinish:
|
||||
// Live and resume must agree, exactly as for a decode checkpoint: the row was written by the
|
||||
// live path with this finish and no text, and re-reading it has to resolve to the same verdict
|
||||
// instead of falling through to «empty completion», which is retryable and would re-buy the
|
||||
// generation this whole class exists to stop buying twice.
|
||||
//
|
||||
// ⚠ It does NOT move classifierVersion, and that is provable rather than hoped: the constant
|
||||
// guards a change that could RE-VERDICT a stored checkpoint, and no checkpoint written before
|
||||
// this line can carry this finish_reason — the string did not exist and no provider emits it.
|
||||
// Bumping it would re-snapshot every book in the world to change the verdict of nothing.
|
||||
// ⚠ ONLY OVER AN EMPTY OUTPUT. The engine writes this finish_reason with no text at all
|
||||
// (cutcall.go); the string itself, though, shares a namespace with whatever a vendor prints —
|
||||
// the adapter already normalises invented values — and a provider answering 200 with a real
|
||||
// translation under this name would otherwise have its answer thrown away as a lost call.
|
||||
if out == "" {
|
||||
return classification{FlagAttemptTimeout, "our own deadline cut a delivered call; the provider generated and billed it, so it is not retried"}
|
||||
}
|
||||
case llm.FinishContentFilter:
|
||||
return classification{FlagContentFilter, "provider finish_reason=content_filter"}
|
||||
case llm.FinishRefusal:
|
||||
|
|
@ -383,19 +460,30 @@ func degenerateLoop(text string) bool {
|
|||
|
||||
// --- max_tokens on the attempt axis (D2.3) ---
|
||||
|
||||
// maxTokensForAttempt is the PURE output-token budget for a retry attempt:
|
||||
// attempt 0 = base, each regeneration DOUBLES it (D2.3 remedy for a length cut —
|
||||
// the previous budget was too small). Purity is load-bearing: the value enters
|
||||
// request_hash, so resume must reproduce the identical per-attempt budget. Its
|
||||
// FORMULA is versioned into the snapshot (maxTokensPolicyVersion) so a change is
|
||||
// a loud --resnapshot, not a silent checkpoint miss on retried chunks (the same
|
||||
// discipline as estimatorVersion).
|
||||
func maxTokensForAttempt(base, attempt int) int {
|
||||
if attempt <= 0 {
|
||||
// maxTokensForAttempt is the PURE output-token budget for a retry attempt: no doublings = base, each
|
||||
// regeneration DOUBLES it (D2.3 remedy for a length cut — the previous budget was too small). Purity
|
||||
// is load-bearing: the value enters request_hash, so resume must reproduce the identical budget. Its
|
||||
// FORMULA is versioned into the snapshot (maxTokensPolicyVersion) so a change is a loud --resnapshot,
|
||||
// not a silent checkpoint miss on retried chunks (the same discipline as estimatorVersion).
|
||||
//
|
||||
// ⛔ IT COUNTS DOUBLINGS, NOT ATTEMPTS, and the two used to be the same number by accident. Every
|
||||
// regeneration is a new attempt, so «attempt index» read as «times the budget was doubled» and both
|
||||
// callers agreed — until a call the ENGINE cut short had to be re-done. That re-do must NOT be paid
|
||||
// for with a doubled budget: nothing was wrong with the answer, nobody saw it, and doubling would buy
|
||||
// twice the call for a health nobody lost. Since a checkpoint cannot be written twice under one key,
|
||||
// the re-do has to be a NEW attempt index — so the two dimensions had to come apart, and this
|
||||
// parameter is the one that is about money.
|
||||
//
|
||||
// ⚠ THE FORMULA AND ITS OUTPUT ARE UNCHANGED FOR EVERY PATH THAT EXISTS TODAY: both loops that
|
||||
// regenerate increment the doubling count with the attempt index, so on a run with no cut call the
|
||||
// two are identical and the snapshot does not move. That equality is the reason this could land on
|
||||
// books that are mid-translation at all — moving maxTokensPolicyVersion would re-pay every one of them.
|
||||
func maxTokensForAttempt(base, escalations int) int {
|
||||
if escalations <= 0 {
|
||||
return base
|
||||
}
|
||||
if attempt > 20 { // defensive: never shift by a runaway amount (overflow guard)
|
||||
attempt = 20
|
||||
if escalations > 20 { // defensive: never shift by a runaway amount (overflow guard)
|
||||
escalations = 20
|
||||
}
|
||||
return base << uint(attempt)
|
||||
return base << uint(escalations)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,20 +138,21 @@ func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID stri
|
|||
// and flipping the verdict OK→flagged. Only a FRESH hop is budget-gated. The identity is BUILT by the
|
||||
// same helper the hop's own runAttempt uses (attemptRequest), so "mirrors runAttempt" is structural
|
||||
// rather than a promise two field lists keep by vigilance.
|
||||
fbHash := RequestHash(r.attemptRequest(st, st.ResolvedHop, snapID, ch, 0, hopMaxTokens, msgs))
|
||||
fbExists, err := r.Store.GetCheckpoint(fbHash)
|
||||
mayHop, err := r.paidAfterBurns(st, st.ResolvedHop, snapID, ch, 0, hopMaxTokens, msgs)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
mayHop := fbExists != nil
|
||||
if !mayHop {
|
||||
// Fresh hop: serialize the budget admission THROUGH the paid settle across the parallel the draft wave draft
|
||||
// workers (R1, escMu). escalationBudgetRemains is a non-atomic read-then-act over EscalationSpentUSD,
|
||||
// so without this N concurrent draft chunks could each read spent<budget and all be admitted →
|
||||
// overshoot by up to N-1 hops. Holding escMu until this function returns (past runAttempt's settle)
|
||||
// makes a later worker read the UPDATED spend, restoring the sequential soft-cap (overshoot ≤ 1 hop).
|
||||
// The $0 replay path above (fbExists != nil) stays lock-free; escalations are the rare content-failure
|
||||
// exception, so the contention is negligible.
|
||||
// The $0 replay path stays lock-free: paidAfterBurns answering `true` returns an ANSWER the store
|
||||
// already holds, and serving it takes no budget and no admission. Everything reaching here is a
|
||||
// purchase — including the one behind a burned key, where the money was spent and the result was
|
||||
// not received, so the next index is bought fresh and belongs under the same admission as any
|
||||
// other fresh hop.
|
||||
r.escMu.Lock()
|
||||
defer r.escMu.Unlock()
|
||||
if mayHop, err = r.escalationBudgetRemains(); err != nil {
|
||||
|
|
@ -165,6 +166,10 @@ func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID stri
|
|||
// life — a hop that queued for headroom would block every other worker's escalation while spending
|
||||
// the money the primary wave needs to finish what it has already started.
|
||||
fb, err := r.runAttempt(ctx, st, st.ResolvedHop, snapID, ch, job, 0, hopMaxTokens, msgs, true, isFinal, false)
|
||||
// ⛔ THE MONEY TRAVELS WHATEVER THE OUTCOME. A hop the engine CUT still cost what it cost, and the
|
||||
// caller adds `out.fb`'s money to the chunk row; returning the error with an empty outcome left that
|
||||
// money in the ledger and nowhere on the row — a position that reads as cheaper than it was.
|
||||
out.fb = fb
|
||||
if err != nil {
|
||||
// An OPTIONAL hop that trips a USD ceiling must NOT abort the whole book
|
||||
// (and re-abort on every resume): the chunk is already flagged, so keep
|
||||
|
|
@ -176,7 +181,7 @@ func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID stri
|
|||
"stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx, "reason", string(primary.cls.Reason))
|
||||
return out, nil
|
||||
}
|
||||
out.attempted, out.fb = true, fb
|
||||
out.attempted = true
|
||||
r.Log.WarnContext(ctx, "stage escalated to a fallback model",
|
||||
"stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx,
|
||||
"primary_reason", string(primary.cls.Reason), "fallback", st.ResolvedHop,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"textmachine/backend/internal/llm"
|
||||
)
|
||||
|
||||
// flagseverity_test.go pins the chapter passport's "worst flag" ranking.
|
||||
|
|
@ -81,23 +85,23 @@ func TestEveryFlagReasonIsRanked(t *testing.T) {
|
|||
if len(vs.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
var lit *ast.BasicLit
|
||||
switch {
|
||||
case isFlagReasonIdent(vs.Type):
|
||||
lit, _ = vs.Values[0].(*ast.BasicLit)
|
||||
default:
|
||||
call, isCall := vs.Values[0].(*ast.CallExpr)
|
||||
if !isCall || !isFlagReasonIdent(call.Fun) || len(call.Args) != 1 {
|
||||
continue
|
||||
}
|
||||
lit, _ = call.Args[0].(*ast.BasicLit)
|
||||
}
|
||||
if lit == nil || lit.Kind != token.STRING {
|
||||
// ⚠ AND A THIRD SPELLING, which this walk used to drop on the floor: the value can be a
|
||||
// conversion of a constant from ANOTHER package — `X FlagReason = FlagReason(llm.Y)` — used so
|
||||
// that a vocabulary the wire, the checkpoint, the flag and the operator all read has ONE
|
||||
// carrier. There is no literal to unquote there, the old code hit `lit == nil` and `continue`d,
|
||||
// and both flags declared that way landed in the unknown bucket while THIS TEST STAYED GREEN.
|
||||
// Measured when it happened: 18 ranks against 16 constants the walk could see.
|
||||
//
|
||||
// So an unreadable value is now a FAILURE, not a skip. A silent `continue` in an
|
||||
// exhaustiveness test is the same defect the test exists to catch, one level up.
|
||||
decl, isDecl := flagReasonValue(vs)
|
||||
if !isDecl {
|
||||
continue
|
||||
}
|
||||
val, err := strconv.Unquote(lit.Value)
|
||||
val, err := decl.resolve()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", vs.Names[0].Name, err)
|
||||
t.Errorf("%s: %v", vs.Names[0].Name, err)
|
||||
continue
|
||||
}
|
||||
if val == "" {
|
||||
continue // reasonOK — the sentinel for "not a flag", deliberately unranked
|
||||
|
|
@ -152,3 +156,116 @@ func isFlagReasonIdent(e ast.Expr) bool {
|
|||
id, ok := e.(*ast.Ident)
|
||||
return ok && id.Name == "FlagReason"
|
||||
}
|
||||
|
||||
// flagValueDecl is a FlagReason constant declaration the walk has recognised, in whichever of the three
|
||||
// spellings it was written.
|
||||
type flagValueDecl struct {
|
||||
lit *ast.BasicLit // `X FlagReason = "y"` or `X = FlagReason("y")`
|
||||
conv string // `X FlagReason = FlagReason(pkg.Y)` — the rendered source expression
|
||||
}
|
||||
|
||||
// convertedFlagValues resolves the conversion spelling. The keys are SOURCE EXPRESSIONS and the values
|
||||
// are compile-time references to the very constants those expressions name, so the map cannot drift in
|
||||
// VALUE — only in membership, and a member it lacks fails loud below instead of being skipped.
|
||||
var convertedFlagValues = map[string]FlagReason{
|
||||
"llm.CutBySelfDeadline": FlagReason(llm.CutBySelfDeadline),
|
||||
"llm.CutByParent": FlagReason(llm.CutByParent),
|
||||
"llm.CutByConnection": FlagReason(llm.CutByConnection),
|
||||
}
|
||||
|
||||
func (d flagValueDecl) resolve() (string, error) {
|
||||
if d.lit != nil {
|
||||
if d.lit.Kind != token.STRING {
|
||||
return "", fmt.Errorf("declared with a non-string value %s — a FlagReason is a string", d.lit.Value)
|
||||
}
|
||||
return strconv.Unquote(d.lit.Value)
|
||||
}
|
||||
v, ok := convertedFlagValues[d.conv]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("declared as a conversion of %s, whose value this test cannot read from the "+
|
||||
"source; add it to convertedFlagValues (a compile-time reference, not a retyped string) so the "+
|
||||
"exhaustiveness check can see it instead of skipping it", d.conv)
|
||||
}
|
||||
return string(v), nil
|
||||
}
|
||||
|
||||
// flagReasonValue recognises a FlagReason declaration in any of its three spellings and reports how its
|
||||
// value can be read. It returns false for a spec that is not a FlagReason at all — an ordinary string
|
||||
// constant sitting in the same block is not this test's business.
|
||||
func flagReasonValue(vs *ast.ValueSpec) (flagValueDecl, bool) {
|
||||
typed := isFlagReasonIdent(vs.Type)
|
||||
if lit, ok := vs.Values[0].(*ast.BasicLit); ok {
|
||||
return flagValueDecl{lit: lit}, typed
|
||||
}
|
||||
call, isCall := vs.Values[0].(*ast.CallExpr)
|
||||
if !isCall || !isFlagReasonIdent(call.Fun) || len(call.Args) != 1 {
|
||||
return flagValueDecl{}, false
|
||||
}
|
||||
if lit, ok := call.Args[0].(*ast.BasicLit); ok {
|
||||
return flagValueDecl{lit: lit}, true
|
||||
}
|
||||
return flagValueDecl{conv: types.ExprString(call.Args[0])}, true
|
||||
}
|
||||
|
||||
// TestTheSeverityTableMeansWhatItsCommentsSay pins the ORDER, not the membership. The exhaustiveness
|
||||
// test beside it proves every reason has a rank; it says nothing about what the ranks are, so the whole
|
||||
// table could be re-ordered — `cancelled` declared a chapter's worst problem, a lost chunk declared its
|
||||
// mildest — and the battery would stay green.
|
||||
//
|
||||
// Every assertion below is a sentence the table already writes about itself, turned into a check. That
|
||||
// is the point: a rank is a claim, and a claim with no carrier is a comment.
|
||||
func TestTheSeverityTableMeansWhatItsCommentsSay(t *testing.T) {
|
||||
rank := func(r FlagReason) int {
|
||||
s, ok := flagSeverity[r]
|
||||
if !ok {
|
||||
t.Fatalf("premise broken: %s carries no rank, so the comparisons below compare nothing", r)
|
||||
}
|
||||
return s
|
||||
}
|
||||
// The control first: the table is not empty and not all one value, or every «is milder than» below
|
||||
// would hold trivially.
|
||||
seen := map[int]bool{}
|
||||
for _, s := range flagSeverity {
|
||||
seen[s] = true
|
||||
}
|
||||
if len(flagSeverity) < 5 || len(seen) < 3 {
|
||||
t.Fatalf("premise broken: %d reasons across %d distinct ranks — an ordering test needs an order",
|
||||
len(flagSeverity), len(seen))
|
||||
}
|
||||
|
||||
// «The mildest mark there is» — a passport that reported `cancelled` as a chapter's worst problem
|
||||
// would hide a durable finding behind a state the next run erases.
|
||||
for r, s := range flagSeverity {
|
||||
if r == FlagCancelled {
|
||||
continue
|
||||
}
|
||||
if rank(FlagCancelled) <= s {
|
||||
t.Fatalf("`cancelled` must be milder than every other mark — it is the only one the engine "+
|
||||
"removes by itself — but it ranks %d against %s at %d", rank(FlagCancelled), r, s)
|
||||
}
|
||||
}
|
||||
// «They rank together because they are the same thing to a reader — the chunk is lost and the money
|
||||
// is spent.» A lost connection is not a third member: it reaches no disposition, so it wears no rank.
|
||||
if rank(FlagDecodeError) != rank(FlagAttemptTimeout) {
|
||||
t.Fatalf("the paid-and-nothing-came-back reasons must share one rank: decode=%d timeout=%d",
|
||||
rank(FlagDecodeError), rank(FlagAttemptTimeout))
|
||||
}
|
||||
// «An unrecognised string must not out-rank a diagnosis the engine actually made.»
|
||||
for r, s := range flagSeverity {
|
||||
if severityUnknown <= s {
|
||||
t.Fatalf("severityUnknown (%d) must be milder than every diagnosis the engine makes, but %s "+
|
||||
"ranks %d", severityUnknown, r, s)
|
||||
}
|
||||
}
|
||||
// «Ranked below a budget symptom (the chunk is not lost)» — a stripped chunk SHIPPED.
|
||||
if rank(FlagSanitizerStripped) <= rank(FlagLength) || rank(FlagSanitizerStripped) <= rank(FlagEmpty) {
|
||||
t.Fatalf("an auto-cleaned chunk that SHIPPED must be milder than a budget symptom that lost one: "+
|
||||
"stripped=%d length=%d empty=%d", rank(FlagSanitizerStripped), rank(FlagLength), rank(FlagEmpty))
|
||||
}
|
||||
// «Ranked with the deterministic content failures, above a mere budget symptom» — a DROPPED
|
||||
// contaminated output is unreadable as shipped.
|
||||
if rank(FlagSanitizerDefect) >= rank(FlagLength) {
|
||||
t.Fatalf("a contaminated output that was DROPPED must be more severe than a budget symptom: "+
|
||||
"defect=%d length=%d", rank(FlagSanitizerDefect), rank(FlagLength))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
package pipeline
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"textmachine/backend/internal/llm"
|
||||
"textmachine/backend/internal/store"
|
||||
)
|
||||
|
||||
|
|
@ -199,3 +201,59 @@ func (p pos) name() string {
|
|||
}
|
||||
return fmt.Sprintf("ch%d/chunk%d/%s", p.chapter, p.chunkIdx, p.stage)
|
||||
}
|
||||
|
||||
// estimatedSpend is how much of a book's committed money is an ESTIMATE rather than a figure the
|
||||
// provider reported, and over how many calls. It sits beside paidTail because it asks the same kind of
|
||||
// question of the same rows: that one decomposes committed spend by what it BOUGHT, this one by how
|
||||
// well it is KNOWN.
|
||||
//
|
||||
// It is the engine's half of PD-441 and the condition attached to D39.230 п.1. Without it the platform
|
||||
// bills committed_usd and can say «at least this much», never «at least X, up to Y».
|
||||
//
|
||||
// ⛔ THE TEST IS «PAID WITH NO TOKENS TO SHOW FOR IT», not a list of causes. Cost is derived from usage
|
||||
// everywhere else in the engine, so a settled row with zero tokens and a non-zero cost can only have
|
||||
// come from a reservation estimate — whatever ended the call: a body that would not decode, a deadline
|
||||
// of ours, a stopped run, a broken socket, a paid 2xx that reported no usage. A list of finish_reasons
|
||||
// would answer the same question today and quietly stop answering it the day a sixth way to pay
|
||||
// without a token count is added.
|
||||
//
|
||||
// Reading the checkpoints rather than request_log is what makes the pair a decomposition of
|
||||
// committed_usd instead of a second, telemetry-shaped opinion about it: the two are the same money, and
|
||||
// a resumed run writes no new estimate row while its money stands.
|
||||
//
|
||||
// ⛔ `committed == SUM(checkpoints)` HOLDS ONLY UNTIL A REDRIVE, and the difference is money this
|
||||
// figure would otherwise disown. A redrive DELETES the checkpoints of the stages it re-attacks and
|
||||
// leaves their spend committed — «after a redrive committed(spend) >= SUM(checkpoints), the safe
|
||||
// direction» (store/chunkstatus.go). Derived from the surviving rows alone, the published share then
|
||||
// falls to zero and tells the platform that money nobody can account for was measured. So the gap is
|
||||
// carried INTO the estimate by the same rule the rows are: we hold a cost and have no token count to
|
||||
// justify it. Measured on a live redrive: committed unchanged at $0.001056, estimated dropped to $0.
|
||||
func estimatedSpend(usage []store.CheckpointUsage, committedUSD float64) (rows int, usd float64) {
|
||||
var accounted float64
|
||||
for _, u := range usage {
|
||||
accounted += u.CostUSD
|
||||
}
|
||||
for _, u := range usage {
|
||||
if u.CostUSD <= 0 {
|
||||
continue // derived $0 checkpoints are not calls and cost nothing
|
||||
}
|
||||
var tok llm.Usage
|
||||
if err := json.Unmarshal([]byte(u.UsageJSON), &tok); err != nil {
|
||||
// Unreadable usage on a paid row is the same state as absent usage: a cost we cannot
|
||||
// justify from tokens. Counting it as measured publishes the more comfortable answer.
|
||||
rows, usd = rows+1, usd+u.CostUSD
|
||||
continue
|
||||
}
|
||||
if tok.PromptTokens == 0 && tok.CompletionTokens == 0 && tok.ReasoningTokens == 0 {
|
||||
rows, usd = rows+1, usd+u.CostUSD
|
||||
}
|
||||
}
|
||||
// Committed money with no checkpoint behind it at all: a redrive threw the evidence away and kept
|
||||
// the spend. It is counted as ONE more unaccounted line rather than as a per-call figure, because
|
||||
// how many calls it stood for is exactly what was deleted. The float slack keeps a sum of prices
|
||||
// from inventing a nanodollar of «unaccounted».
|
||||
if gap := committedUSD - accounted; gap > 1e-9 {
|
||||
rows, usd = rows+1, usd+gap
|
||||
}
|
||||
return rows, usd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -349,8 +349,13 @@ func (r *Runner) stepMaxForUnit(p *pricePlan, u editUnit, up unitPrice) float64
|
|||
max := 0.0
|
||||
consider := func(sp stagePrice, sizing, prompt int) {
|
||||
base := r.baseMaxTokensFor(sp.st, sizing)
|
||||
for attempt := 0; attempt <= p.maxRegen; attempt++ {
|
||||
if usd := ledger.EstimateUSD(sp.price, prompt, maxTokensForAttempt(base, attempt), sp.reasoning); usd > max {
|
||||
// The walk is over DOUBLINGS, which is what it always was and now says so: the regeneration cap
|
||||
// bounds how many times a budget may double, and the largest single reservation is the last of
|
||||
// them. The projection is untouched by splitting the doubling count off the attempt index —
|
||||
// re-doing a call the engine cut short adds an attempt at an EXISTING budget, so it buys no
|
||||
// reservation this walk has not already priced.
|
||||
for escalations := 0; escalations <= p.maxRegen; escalations++ {
|
||||
if usd := ledger.EstimateUSD(sp.price, prompt, maxTokensForAttempt(base, escalations), sp.reasoning); usd > max {
|
||||
max = usd
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -300,6 +300,12 @@ func (r *Runner) maybeRepair(ctx context.Context, st config.Stage, snapID string
|
|||
}
|
||||
}
|
||||
att, err := r.runRepairAttempt(ctx, st, snapID, ch, job, i, msgs)
|
||||
// ⛔ THE MONEY IS REPORTED BEFORE THE OUTCOME IS JUDGED. A repair call the engine CUT still cost
|
||||
// what it cost, and the caller's chunk row is the only place that cost can be recorded — the
|
||||
// error path used to return before this and left the row below the ledger for the position.
|
||||
res.CostUSD += att.runCost
|
||||
res.CumUSD += att.cumCost
|
||||
res.Fresh = res.Fresh || att.freshCall
|
||||
if err != nil {
|
||||
// A ceiling denial must NOT abort the book: this step is optional and sits BEFORE the
|
||||
// chunk_status write, so propagating would discard the row of an already-paid, successful
|
||||
|
|
@ -312,9 +318,6 @@ func (r *Runner) maybeRepair(ctx context.Context, st config.Stage, snapID string
|
|||
return finalText, res, err
|
||||
}
|
||||
res.Calls++
|
||||
res.CostUSD += att.runCost
|
||||
res.CumUSD += att.cumCost
|
||||
res.Fresh = res.Fresh || att.freshCall
|
||||
spent += att.runCost // a replay costs 0, so only fresh calls consume the budget
|
||||
reply, verdict := repairReplyVerdict(att, dstSpan, c.Class, cfg.Checkers)
|
||||
switch verdict {
|
||||
|
|
@ -361,11 +364,16 @@ func (r *Runner) maybeRepair(ctx context.Context, st config.Stage, snapID string
|
|||
// mirrors runRepairAttempt's request identity BY CONSTRUCTION — same derived stage, same helper — because
|
||||
// the two must address the same checkpoint; the doccomment used to promise that mirroring while the two
|
||||
// field lists were maintained apart, which held only while the dropped fields were zero.
|
||||
//
|
||||
// ⛔ A BURNED KEY IS NOT A PAID REPAIR. A row that records money and no result cannot be replayed, so
|
||||
// runAttempt walks past it and buys the repair again — and «already paid» is what skips the sub-budget
|
||||
// comparison two lines up from the call, so that purchase would happen with nothing bounding it. The key
|
||||
// can hold such a row only because cut calls are settled there now; before that, «a checkpoint exists»
|
||||
// and «this repair is done» were the same statement. Same reading as the bank probe's.
|
||||
func (r *Runner) repairCheckpointExists(st config.Stage, snapID string, ch chunk.Chunk, ordinal int, msgs []llm.Message) (bool, error) {
|
||||
model, maxTokens := r.repairCallBudget(msgs)
|
||||
rst := r.repairStage(st, model)
|
||||
cp, err := r.Store.GetCheckpoint(RequestHash(r.attemptRequest(rst, model, snapID, ch, ordinal, maxTokens, msgs)))
|
||||
return cp != nil, err
|
||||
return r.paidAfterBurns(rst, model, snapID, ch, ordinal, maxTokens, msgs)
|
||||
}
|
||||
|
||||
// repairStage derives the stage of a repair call from the stage whose output is being repaired. It takes
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"textmachine/backend/internal/chunk"
|
||||
"textmachine/backend/internal/config"
|
||||
"textmachine/backend/internal/ledger"
|
||||
|
|
@ -35,7 +36,7 @@ import (
|
|||
// able to tell that a repaired span did not drop a canonical glossary form (with the post-check gate on that
|
||||
// would flip the unit to flagged and ship an EMPTY export). It is threaded rather than re-derived so the
|
||||
// re-gate judges the SAME selection the injection was rendered from.
|
||||
func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, snapID string, ch chunk.Chunk, prev, injection string, injected []membank.PickedEntry) (*StageResult, error) {
|
||||
func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, snapID string, ch chunk.Chunk, prev, injection string, injected []membank.PickedEntry) (res *StageResult, err error) {
|
||||
// Enrich ReqInfo FIRST, PRESERVING the admission decisions (LogBodies) —
|
||||
// a from-scratch overwrite would sever the documented debug channel (review
|
||||
// finding). Earlier the enrichment sat AFTER the resume-fast-path — and every
|
||||
|
|
@ -89,6 +90,7 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
|
|||
if cs, err := r.Store.GetChunkStatus(r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name); err != nil {
|
||||
return nil, fmt.Errorf("pipeline: read chunk_status %s/ch%d/chunk%d/%s: %w", r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, err)
|
||||
} else if cs != nil && cs.ContentHash == contentHash && cs.Disposition != string(DispSkipped) &&
|
||||
resolvedForResume(cs) &&
|
||||
(cs.SnapshotID == snapID || r.repinnable(cs.SnapshotID, snapID, waveOfStage(r.Pipeline.Stages, st.Name))) {
|
||||
// POINTWISE RE-EDIT (pack-20 point 5). The exact-snapshot case is the ordinary resume. The second
|
||||
// case is the one that makes signing a term affordable: the snapshot moved ONLY because the BANK
|
||||
|
|
@ -137,45 +139,79 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
|
|||
var last stageAttempt
|
||||
anyFresh := false
|
||||
attemptsMade := 0
|
||||
// ⛔ THE MARK FOR A STOPPED POSITION IS ATTACHED TO EVERY EXIT, not to the attempt loop's. It was
|
||||
// written at the loop's error return, and the hop and the repair sub-step both leave this function
|
||||
// through OTHER returns — so a run stopped over an escalation hop settled the
|
||||
// money and left NO chunk_status row at all: the invisible hole §4.2 forbids, measured as
|
||||
// `committed=0.001176` with `chunk_status_rows=0`. A deferred rule sees whichever return fires, and
|
||||
// closes over the counters so it reports what had accumulated by then. recordCancelledStage is a
|
||||
// no-op for every error that is not a delivered call a person stopped, so this costs nothing on the
|
||||
// ordinary paths.
|
||||
defer func() {
|
||||
r.recordCancelledStage(ctx, cancelledPosition{
|
||||
stage: st, chunk: ch, snapshotID: snapID, contentHash: contentHash,
|
||||
cumCostUSD: cumCost, attempts: attemptsMade,
|
||||
}, err)
|
||||
}()
|
||||
// firstFlagReason keeps the FIRST attempt's failure when a later attempt (a regenerate, or the
|
||||
// single-hop escalation below) recovers the chunk. Without it the recovered row is written `ok`
|
||||
// with an empty flag_reason and the primary failure leaves no durable trace at all — which is how
|
||||
// the mini-run of 25.07 reported echo_draft=0.0% on a run where one draft in twenty had echoed.
|
||||
// It is telemetry, never the verdict: `disposition`/`FlagReason` below are untouched by it.
|
||||
firstFlagReason := FlagReason("")
|
||||
// escalations counts BUDGET DOUBLINGS; attempt counts KEYS. They move together on every
|
||||
// regeneration and come apart on exactly one path: re-doing a call the engine itself cut short,
|
||||
// which needs a fresh request_hash (the old one already holds that call's money) at the budget it
|
||||
// was already granted. See maxTokensForAttempt.
|
||||
escalations := 0
|
||||
judged := 0
|
||||
for attempt := 0; ; attempt++ {
|
||||
maxTokens := maxTokensForAttempt(baseMaxTokens, attempt)
|
||||
maxTokens := maxTokensForAttempt(baseMaxTokens, escalations)
|
||||
att, err := r.runAttempt(ctx, st, st.ResolvedModel, snapID, ch, job, attempt, maxTokens, msgs, false, isFinal, true)
|
||||
if err != nil {
|
||||
return nil, err // infra failure
|
||||
}
|
||||
attemptsMade = attempt + 1
|
||||
cumCost += att.cumCost
|
||||
runCost += att.runCost
|
||||
// ⛔ THE COUNT IS A FACT ABOUT WHAT HAPPENED, not about whether it succeeded — and it is recorded
|
||||
// before the error check for the same reason the money above is. The deferred mark reports this
|
||||
// number BESIDE money that was really paid; leaving it behind the check wrote `attempts=0` on a
|
||||
// position whose ledger said 0.001056. runAttempt may also have walked over burned keys, so the
|
||||
// loop follows its index and the next regeneration does not re-address a key that is spent.
|
||||
attempt = att.attempt
|
||||
attemptsMade = attempt + 1
|
||||
if err != nil {
|
||||
return nil, err // infra failure; the cancelled-position mark is the defer above
|
||||
}
|
||||
anyFresh = anyFresh || att.freshCall
|
||||
last = att
|
||||
if attempt == 0 && !att.cls.ok() {
|
||||
if judged == 0 && !att.cls.ok() {
|
||||
firstFlagReason = att.cls.Reason
|
||||
}
|
||||
judged++
|
||||
if att.cls.ok() {
|
||||
break
|
||||
}
|
||||
// Flagged: re-attack only the retryable subset, only while regenerations
|
||||
// remain (a bigger budget on the attempt axis, D2.3). Everything else is
|
||||
// deterministic — a same-model retry would re-refuse and re-bill (D2.2).
|
||||
if att.cls.Reason.retryable() && attempt < maxRegen {
|
||||
if att.cls.Reason.retryable() && escalations < maxRegen {
|
||||
r.Log.WarnContext(ctx, "stage flagged, regenerating with a larger budget",
|
||||
"stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx,
|
||||
"attempt", attempt, "reason", string(att.cls.Reason), "next_max_tokens", maxTokensForAttempt(baseMaxTokens, attempt+1))
|
||||
"attempt", attempt, "reason", string(att.cls.Reason), "next_max_tokens", maxTokensForAttempt(baseMaxTokens, escalations+1))
|
||||
escalations++
|
||||
continue
|
||||
}
|
||||
// Echo (cjk_artifact) OPT-IN re-generation before escalation (row 77 / D39.61): on a provider whose
|
||||
// echo is STOCHASTIC per call, a same-model re-gen recovers ~7.6× cheaper than the escalation hop.
|
||||
// Default 0 ⇒ this never fires and echo escalates straight away (the prior behaviour); the echo GATE
|
||||
// is untouched — only the RESPONSE changes.
|
||||
if att.cls.Reason == FlagCJKArtifact && attempt < echoRegen {
|
||||
if att.cls.Reason == FlagCJKArtifact && escalations < echoRegen {
|
||||
r.Log.WarnContext(ctx, "echo flagged, regenerating before escalation (echo is stochastic per call, D39.61)",
|
||||
"stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx, "attempt", attempt)
|
||||
// ⚠ THE ECHO RE-GEN COUNTS AS A DOUBLING TOO, and it must. It is a fresh roll of a
|
||||
// stochastic die rather than a bigger-budget remedy, so counting it here looks like a
|
||||
// detail — but this loop has always given it `base << attempt`, and taking that away would
|
||||
// move max_tokens, and with it request_hash, and with it every echo-regenerated
|
||||
// checkpoint on disk. The doubling axis was split to add a case, not to re-price one.
|
||||
escalations++
|
||||
continue
|
||||
}
|
||||
break
|
||||
|
|
@ -200,13 +236,17 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
|
|||
// column.
|
||||
escalated, escModel := false, ""
|
||||
esc, err := r.maybeEscalate(ctx, st, snapID, ch, job, baseMaxTokens, msgs, last, isFinal)
|
||||
// ⛔ MONEY FIRST, VERDICT SECOND — and the order is the whole point. A hop that was CUT reports its
|
||||
// cost through `esc.fb` and an error at the same time; adding the cost only on the success path left
|
||||
// the deferred `cancelled` mark carrying the primary's money alone (measured: ledger 0.001176, row
|
||||
// 0.000120). `esc.fb` is a zero value when no hop ran, so this adds nothing when nothing happened.
|
||||
cumCost += esc.fb.cumCost
|
||||
runCost += esc.fb.runCost
|
||||
anyFresh = anyFresh || esc.fb.freshCall
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if esc.attempted {
|
||||
cumCost += esc.fb.cumCost
|
||||
runCost += esc.fb.runCost
|
||||
anyFresh = anyFresh || esc.fb.freshCall
|
||||
escalated = true
|
||||
if esc.fb.cls.ok() || esc.fb.cls.Reason == FlagSanitizerStripped {
|
||||
// The fallback is authoritative when it passed the re-gate OR when it is a cosmetic
|
||||
|
|
@ -246,12 +286,14 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
|
|||
// intermediate artifact rather than the final one.
|
||||
if isFinal {
|
||||
repaired, rr, rerr := r.maybeRepair(ctx, st, snapID, ch, job, prev, last.text, injected)
|
||||
// Same order as the hop above: a repair call the engine cut reports its cost together with
|
||||
// the error, and the row is the only place that cost can land.
|
||||
cumCost += rr.CumUSD // honest total: a replayed repair still costs what it cost
|
||||
runCost += rr.CostUSD // this run's spend only
|
||||
if rerr != nil {
|
||||
return nil, rerr
|
||||
}
|
||||
repairRes = rr
|
||||
cumCost += rr.CumUSD // honest total: a replayed repair still costs what it cost
|
||||
runCost += rr.CostUSD // this run's spend only
|
||||
anyFresh = anyFresh || rr.Fresh
|
||||
if rr.Applied > 0 {
|
||||
dh, derr := r.commitRepairExport(st, ch, job, last, repaired)
|
||||
|
|
@ -433,8 +475,42 @@ func (r *Runner) callEstimateUSD(st config.Stage, model string, msgs []llm.Messa
|
|||
// dollars would turn «do not start anything new» into «the optional spends what the mandatory needed»,
|
||||
// and the hop holds escMu while it waits, so it would also block every other worker's escalation.
|
||||
func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID string, ch chunk.Chunk, job *store.Job, attempt, maxTokens int, msgs []llm.Message, escalation, isFinal, mandatory bool) (stageAttempt, error) {
|
||||
reqHash := RequestHash(r.attemptRequest(st, model, snapID, ch, attempt, maxTokens, msgs))
|
||||
att := stageAttempt{reqHash: reqHash, attempt: attempt, modelActual: model}
|
||||
// ⛔ BURNED KEYS ARE WALKED OVER HERE, AND NOT BY THE CALLER. A checkpoint that records money and
|
||||
// no result cannot be replayed as an answer, so its attempt index is spent and the call has to be
|
||||
// re-asked under the next one at the SAME budget. That rule first lived in runStage's loop, which
|
||||
// left the three callers OUTSIDE the loop with a key burnt forever: the escalation hop addresses a
|
||||
// fixed attempt 0, so a run stopped over a hop could never re-do it — measured as `committed`
|
||||
// booked, `chunk_status_rows = 0` and a resume that made ZERO fresh calls. The bank batches and the
|
||||
// repair sub-step have the same shape. Walking here gives every caller the behaviour without any of
|
||||
// them knowing the rule exists.
|
||||
//
|
||||
// The walk terminates by construction: it advances only while a checkpoint EXISTS at the key, and
|
||||
// the first key without one takes the fresh-call path below. The money of every key it steps over
|
||||
// stays in the chunk's honest total — it was really spent — while `runCost` does not move, because
|
||||
// a burn can only have been settled by an EARLIER run (both causes end this one).
|
||||
var burnedCost float64
|
||||
var reqHash string
|
||||
var att stageAttempt
|
||||
for {
|
||||
reqHash = RequestHash(r.attemptRequest(st, model, snapID, ch, attempt, maxTokens, msgs))
|
||||
att = stageAttempt{reqHash: reqHash, attempt: attempt, modelActual: model}
|
||||
cp, cerr := r.Store.GetCheckpoint(reqHash)
|
||||
if cerr != nil {
|
||||
return att, fmt.Errorf("pipeline: read checkpoint for %s/ch%d/chunk%d/%s attempt %d: %w", r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, attempt, cerr)
|
||||
}
|
||||
if cp == nil || !burnedByCut(cp) {
|
||||
break
|
||||
}
|
||||
burnedCost += cp.CostUSD
|
||||
// «Spent», not «paid for»: the key is used up either way, but what it COST is printed beside it
|
||||
// and is legitimately zero when the provider never acknowledged the call. A line calling a $0
|
||||
// row «paid for» reads as a billing bug to whoever finds it in a log at three in the morning.
|
||||
r.Log.InfoContext(ctx, "the key of this attempt is SPENT — the call was cut off before its answer arrived and cannot be replayed; re-asking under a fresh key at the SAME budget",
|
||||
"stage", st.Name, "attempt", attempt, "hash", reqHash[:12], "cause", cp.FinishReason,
|
||||
"max_tokens", maxTokens, "cost_usd", fmt.Sprintf("%.6f", cp.CostUSD))
|
||||
attempt++
|
||||
}
|
||||
att.cumCost = burnedCost
|
||||
|
||||
// Resume on the attempt axis: a checkpoint means THIS attempt already happened
|
||||
// and was billed — classify its text and never re-bill (kill -9 loses ≤1 call;
|
||||
|
|
@ -451,7 +527,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
|
|||
att.usage = usage
|
||||
att.finish = cp.FinishReason
|
||||
att.modelActual = cp.ModelActual
|
||||
att.cumCost = cp.CostUSD
|
||||
att.cumCost = burnedCost + cp.CostUSD
|
||||
// Banknote: slice the block off the RAW checkpoint text BEFORE classify + before feeding the
|
||||
// editor (WS4 points 1-3,8). The checkpoint stores the RAW draft; resume re-derives the clean
|
||||
// text deterministically (a no-op returning the raw text for a channel-off / no-separator draft).
|
||||
|
|
@ -551,6 +627,24 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
|
|||
estimate, r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, ctx.Err())
|
||||
}
|
||||
}
|
||||
// ⛔ A RUN THAT IS ENDING DID NOT STOP ON MONEY, whatever the ledger says at this instant, and the
|
||||
// check belongs HERE rather than at the top of the loop — that is where it was first written, and
|
||||
// it missed. `waitForSettle` answers «nothing is in flight» BEFORE it looks at the context, so a
|
||||
// worker whose wait ended because the cancelled call had already settled and left falls straight
|
||||
// through the switch above to the halt below, with its context long dead and the top-of-loop
|
||||
// check minutes behind it. The decision point is the only place a guard on it is complete.
|
||||
//
|
||||
// Why it matters now: the hole was unreachable while a cancelled in-flight call gave its
|
||||
// reservation back. Paying for such a call (D39.230 п.1) makes it live — a stop commits every
|
||||
// flying call's estimate, which can carry the book past its own ceiling — and the next worker
|
||||
// would publish a `ceiling` event for a run a person had stopped themselves. The platform lets
|
||||
// that event survive any exit code, so it would record `paused` and ask for money that would
|
||||
// change nothing.
|
||||
if ctx.Err() != nil {
|
||||
r.setJobStatus(ctx, job.ID, "failed")
|
||||
return att, fmt.Errorf("pipeline: reserve $%.6f for %s/ch%d/chunk%d/%s: the run ended before the reservation was granted: %w",
|
||||
estimate, r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, ctx.Err())
|
||||
}
|
||||
if verdict == store.ReserveDeniedBook {
|
||||
// Name WHICH ceiling stopped the run: a caller who passed --ceiling-usd and is told to "raise
|
||||
// ceilings.book_usd" would edit a file that is not in force (row 145).
|
||||
|
|
@ -595,6 +689,12 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
|
|||
r.Log.InfoContext(ctx, "calling model", "model", model, "attempt", attempt,
|
||||
"max_tokens", maxTokens, "escalation", escalation, "estimate_usd", fmt.Sprintf("%.6f", estimate))
|
||||
|
||||
// A call may now legitimately run for a quarter of an hour: the deadline is derived from the
|
||||
// budget, and the owner ratified waiting for a provider that has our request. That makes the ONE
|
||||
// line above ("calling model") insufficient — a fifteen-minute silence after it is
|
||||
// indistinguishable from a wedged process, which is exactly the pain a visible in-flight marker was
|
||||
// added for in the first place. So the wait says so while it lasts, and stops the moment the call does.
|
||||
stopHeartbeat := r.logWaitingForProvider(ctx, st, ch, model, attempt, maxTokens)
|
||||
start := time.Now()
|
||||
resp, err := client.Complete(ctx, llm.LLMRequest{
|
||||
Model: model,
|
||||
|
|
@ -604,6 +704,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
|
|||
ReasoningEffort: st.Reasoning,
|
||||
})
|
||||
att.latency = int(time.Since(start).Milliseconds())
|
||||
stopHeartbeat()
|
||||
if err != nil {
|
||||
var bde *llm.BilledDecodeError
|
||||
if errors.As(err, &bde) {
|
||||
|
|
@ -621,7 +722,11 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
|
|||
}
|
||||
r.events.flush()
|
||||
att.finish = decodeErrorFinish
|
||||
att.cumCost, att.runCost = estimate, estimate
|
||||
// `burnedCost +`, not `=`: the walk above may have stepped over keys this position already
|
||||
// paid for, and that money is this chunk's whatever this attempt turns out to be. Overwriting
|
||||
// it puts chunk_status.cost_usd below SUM(checkpoints) for the position — the ledger's own
|
||||
// invariant, read by the projection a person decides on.
|
||||
att.cumCost, att.runCost = burnedCost+estimate, estimate
|
||||
att.cls = classification{FlagDecodeError, "billed 2xx with an unreadable body: " + err.Error()}
|
||||
rl := r.baseRequestLog(st, ch, model, reqHash)
|
||||
rl.CostUSD, rl.LatencyMS, rl.FinishReason = estimate, att.latency, decodeErrorFinish
|
||||
|
|
@ -631,9 +736,22 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
|
|||
r.setJobStatus(ctx, job.ID, "done")
|
||||
return att, nil
|
||||
}
|
||||
// No 2xx ever arrived: nothing was billed. Release and surface as an INFRA
|
||||
var cut *llm.AttemptCutError
|
||||
if errors.As(err, &cut) && cut.Delivered {
|
||||
return r.settleCutCall(ctx, cutCall{
|
||||
stage: st, chunk: ch, job: job, model: model, reqHash: reqHash,
|
||||
resv: resv, estimate: estimate, attempt: attempt, escalation: escalation,
|
||||
}, cut, err, att)
|
||||
}
|
||||
// The request never went out: nothing was billed. Release and surface as an INFRA
|
||||
// failure — a long book pauses/resumes on an outage or terminal 4xx (D4),
|
||||
// rather than flag-storming every remaining chunk on a dead provider.
|
||||
//
|
||||
// ⚠ THE CONDITION IS DELIVERY, NOT A STATUS LINE, and it used to be neither: the branch read
|
||||
// «No 2xx ever arrived ⇒ nothing was billed», which is false on a provider that answers 200
|
||||
// while the request is still queued. Every call our own deadline cut fell in here or into the
|
||||
// decode-error branch beside it, and this one gave the ledger a zero for a generation the
|
||||
// provider had made and billed.
|
||||
r.releaseReservation(ctx, resv)
|
||||
rl := r.baseRequestLog(st, ch, model, reqHash)
|
||||
rl.LatencyMS, rl.Err, rl.OK = att.latency, err.Error(), false
|
||||
|
|
@ -679,7 +797,8 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
|
|||
r.setJobStatus(ctx, job.ID, "failed")
|
||||
return att, err
|
||||
}
|
||||
att.cumCost, att.runCost = cost, cost
|
||||
// Same as the billed-decode branch above: the burn walk's money is added, never replaced.
|
||||
att.cumCost, att.runCost = burnedCost+cost, cost
|
||||
|
||||
// Money: settle + the raw response — one transaction (§3.3). ALWAYS, even for
|
||||
// an empty/truncated/refused response: it was billed by the provider, the
|
||||
|
|
@ -814,3 +933,66 @@ func (r *Runner) baseMaxTokensFor(st config.Stage, sizingTokens int) int {
|
|||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// --- the visible life of a call in flight ---
|
||||
|
||||
// waitHeartbeat bounds how often a call still in flight says so: often enough that an operator learns
|
||||
// within a minute, rarely enough that a healthy wave prints nothing.
|
||||
const waitHeartbeat = time.Minute
|
||||
|
||||
// heartbeatEvery is the interval for a call that may run for `deadline` — a quarter of the wait the
|
||||
// engine has actually granted, capped at a minute. Derived rather than fixed so the line exists on
|
||||
// every call the engine is willing to wait for, including short ones.
|
||||
func heartbeatEvery(deadline time.Duration) time.Duration {
|
||||
q := deadline / 4
|
||||
if q <= 0 {
|
||||
// A wait of zero or less is a caller that could not resolve one, not a call that finishes
|
||||
// instantly. Falling to a millisecond here would print this line a thousand times a second,
|
||||
// which is the opposite of what it is for; the cap is the honest answer to «no idea how long».
|
||||
return waitHeartbeat
|
||||
}
|
||||
if q < waitHeartbeat {
|
||||
return q
|
||||
}
|
||||
return waitHeartbeat
|
||||
}
|
||||
|
||||
// logWaitingForProvider says, while a call is still out, that it is — and returns the function that
|
||||
// stops it. With a deadline derived from the budget a call may legitimately run for a quarter of an
|
||||
// hour, and a single «calling model» line then leaves an operator watching a silence he cannot tell
|
||||
// from a wedged process (the in-flight marker's original reason, met again at a longer timescale).
|
||||
func (r *Runner) logWaitingForProvider(ctx context.Context, st config.Stage, ch chunk.Chunk, model string, attempt, maxTokens int) (stop func()) {
|
||||
done := make(chan struct{})
|
||||
var once sync.Once
|
||||
go func() {
|
||||
t := time.NewTicker(heartbeatEvery(r.Models.AttemptDeadline(model, maxTokens)))
|
||||
defer t.Stop()
|
||||
started := time.Now()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
// ⚠ IT DOES NOT CLAIM DELIVERY, and it used to. The runner cannot see the transport's
|
||||
// trace, so «a call that has been delivered» was an assertion nobody had checked — and on
|
||||
// a request that never went out it printed eighteen times, telling the operator the
|
||||
// opposite of what happened. A line stating a fact it did not ask about is the same
|
||||
// defect a flag naming the wrong cause is (D39.93 п.2).
|
||||
// ⚠ THE FIELDS ARE NAMED FOR WHAT THEY ACTUALLY ARE, and the first version's were not.
|
||||
// This timer wraps client.Complete — the WHOLE retry chain — while the deadline it can
|
||||
// name is one ATTEMPT's, so `waited=4s of=1s` printed a contradiction and read as the
|
||||
// hung process the line exists to rule out. `attempt_deadline` says which of the two it
|
||||
// is, and a `waited` past it means the transport has retried. `stage_attempt` likewise:
|
||||
// the transport logs an `attempt` of its own, and two different numbers under one key in
|
||||
// one stream is a question an operator cannot answer.
|
||||
r.Log.InfoContext(ctx, "still waiting for the provider on a call in flight",
|
||||
"stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx, "model", model,
|
||||
"stage_attempt", attempt, "waited", time.Since(started).Round(time.Millisecond).String(),
|
||||
"attempt_deadline", r.Models.AttemptDeadline(model, maxTokens).String())
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { once.Do(func() { close(done) }) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -306,7 +306,20 @@ type StatusReport struct {
|
|||
// disposition — a nonzero count is "attention worth a human glance", not a failed chunk.
|
||||
StyleFlags int `json:"style_flags"`
|
||||
|
||||
CommittedUSD float64 `json:"committed_usd"`
|
||||
CommittedUSD float64 `json:"committed_usd"`
|
||||
// EstimatedRows / EstimatedUSD are the part of CommittedUSD that is an ESTIMATE — money booked for
|
||||
// a call whose token count we never received, because the body would not decode, because our own
|
||||
// deadline or a stopped run cut a delivered request short, or because the provider answered 2xx
|
||||
// with no usage at all (estimatedSpend, cutcall.go).
|
||||
//
|
||||
// They ride HERE, beside the figure they qualify, because this is the JSON the platform reads and
|
||||
// bills a user from. The engine has printed an estimated-cost legend for a human since pack-13;
|
||||
// what nobody could read was a NUMBER, so the platform could only ever say «the run cost at least
|
||||
// this much» (PD-441). Zero rows publish as zero rather than as absence: «none of it was
|
||||
// estimated» is an answer, and omitting the field would make it indistinguishable from an engine
|
||||
// too old to have one.
|
||||
EstimatedRows int `json:"estimated_rows"`
|
||||
EstimatedUSD float64 `json:"estimated_usd"`
|
||||
ReservedUSD float64 `json:"reserved_usd"`
|
||||
BookCeilingUSD float64 `json:"book_ceiling_usd,omitempty"`
|
||||
CeilingPct float64 `json:"ceiling_pct,omitempty"` // 100·(committed+reserved)/book_ceiling
|
||||
|
|
@ -408,22 +421,38 @@ var flagSeverity = map[FlagReason]int{
|
|||
FlagSanitizerDefect: 2,
|
||||
|
||||
FlagLoopDegenerate: 3,
|
||||
FlagDecodeError: 4,
|
||||
FlagGlossaryMiss: 5,
|
||||
FlagLength: 6,
|
||||
FlagEmpty: 6,
|
||||
FlagUpstreamNotOK: 6,
|
||||
|
||||
// A cosmetic leak the sanitizer STRIPPED and exported (D35.4a): the chunk shipped cleaned, so it is
|
||||
// the least alarming flag — an "auto-cleaned, glance to verify" signal, ranked below a budget symptom
|
||||
// (the chunk is not lost; a human need only spot-check the auto-clean).
|
||||
// Paid, and nothing usable came back. `decode_error` is a 2xx whose body would not parse;
|
||||
// `attempt_timeout` is a call OUR deadline cut while the provider was still generating it. They rank
|
||||
// together because they are the same thing to a reader — the chunk is lost and the money is spent —
|
||||
// and because neither is a verdict about the TEXT: both say the transport or its deadline needs
|
||||
// fixing, and both are re-driveable once it is. A lost connection is NOT a third member: it never
|
||||
// reaches a disposition at all (see disposition.go), so a rank for it would be a rank nothing wears.
|
||||
FlagDecodeError: 4,
|
||||
FlagAttemptTimeout: 4,
|
||||
|
||||
FlagGlossaryMiss: 5,
|
||||
FlagLength: 6,
|
||||
FlagEmpty: 6,
|
||||
FlagUpstreamNotOK: 6,
|
||||
|
||||
// A cosmetic leak the sanitizer STRIPPED and exported (D35.4a): the chunk shipped cleaned — an
|
||||
// "auto-cleaned, glance to verify" signal, ranked below a budget symptom (the chunk is not lost).
|
||||
FlagSanitizerStripped: 7,
|
||||
|
||||
// The mildest mark there is, and the only one the engine removes by itself: a person stopped the run
|
||||
// over a call that had already gone out, so the position is paid for and NOT done — and the next
|
||||
// resume re-does it on the same budget. It ranks below the auto-clean because that one is a durable
|
||||
// property of shipped text a human should look at, while this is a state the next run erases; a
|
||||
// passport that reported «cancelled» as a chapter's worst problem would hide the durable finding
|
||||
// behind a transient one.
|
||||
FlagCancelled: 8,
|
||||
}
|
||||
|
||||
// severityUnknown is where a reason this build has never heard of lands — a row written by an older
|
||||
// schema, or junk. Last on purpose: an unrecognised string must not out-rank a diagnosis the engine
|
||||
// actually made. It is NOT a resting place for new flags; the exhaustiveness test is what keeps it empty.
|
||||
const severityUnknown = 8
|
||||
const severityUnknown = 9
|
||||
|
||||
func flagReasonSeverity(reason string) int {
|
||||
if s, ok := flagSeverity[FlagReason(reason)]; ok {
|
||||
|
|
@ -536,6 +565,17 @@ func resolveChunkState(rows []store.ChunkStatus, stagesTotal int) chunkStateReso
|
|||
case string(DispOK):
|
||||
ok++
|
||||
case string(DispFlagged):
|
||||
// ⛔ A ROW THAT IS NOT AN ANSWER DECIDES NOTHING. `cancelled` records a position the engine was
|
||||
// stopped over: the work was never done, the resume re-does it and pays again. Reading it as a
|
||||
// decided unit puts it in the extrapolation's DENOMINATOR (rebill.go's projectBookUSD takes
|
||||
// every done-or-flagged unit as processed), so one stop makes the book look further along and
|
||||
// cheaper than it is — and the re-bill consent threshold, min($0.50, 5% × projected), shrinks
|
||||
// with it. Before cut calls were settled here a stop left NO row at all and the denominator was
|
||||
// honest; the mark must not cost that. Same question as the resume gate's, asked through the
|
||||
// same predicate.
|
||||
if !resolvedForResume(&cs) {
|
||||
continue
|
||||
}
|
||||
// The FIRST flagged stage decides the unit; keep its reason (the rows arrive in member/stage order
|
||||
// with the leader bucket first, so first-wins aligns status with translate/export, which both take
|
||||
// the first flag — a member drop's reason, or the edit's own when the edit flagged). Without the
|
||||
|
|
@ -861,6 +901,15 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
|
|||
return nil, err
|
||||
}
|
||||
rep.CommittedUSD, rep.ReservedUSD = committed, reserved
|
||||
// How much of that committed figure is an estimate. A failed read DEGRADES loudly — the pair stays
|
||||
// zero and the reason is logged — rather than failing the whole read-only projection; but it is
|
||||
// logged, because «0 estimated» read off a failure would be the most expensive silence on this
|
||||
// surface: it says the committed figure is fully measured when nobody looked.
|
||||
if usage, uerr := r.Store.CheckpointUsageForBook(r.Book.BookID); uerr != nil {
|
||||
r.Log.WarnContext(ctx, "status: the estimated share of the committed spend could not be read; it is UNKNOWN, not zero", "err", uerr)
|
||||
} else {
|
||||
rep.EstimatedRows, rep.EstimatedUSD = estimatedSpend(usage, committed)
|
||||
}
|
||||
// The ceiling IN FORCE (row 145): a read path never carries a run-scoped override, so this is the
|
||||
// book's own number there — but reading it through the single definition means status can never quote
|
||||
// a ceiling the ledger is not admitting against.
|
||||
|
|
|
|||
|
|
@ -476,7 +476,13 @@ func (r *Runner) runTerminologist(ctx context.Context, snapID string, cands []te
|
|||
// here twice — on "the book has paid", which every ordinary resume of a filtered book satisfies, and on
|
||||
// a probe taken after the passes, which a freshly-paid first run satisfies trivially.
|
||||
if res.BankSettled > 0 && run.fresh && paidBefore {
|
||||
r.Log.WarnContext(ctx, "terminology: this book had already paid for the bank role BEFORE this run, and this run bought the pass again — those earlier calls were made under a batch composition the already-banked filter does not reproduce, so their checkpoints could not be found. This is a ONE-TIME cost; every later run replays for $0",
|
||||
// ⚠ THE CAUSE IS NAMED AS A PAIR, because the code cannot tell which of the two happened and a
|
||||
// message that picks one is wrong half the time. The old wording asserted the composition change
|
||||
// alone; a book whose only earlier bank row was BURNED — money settled, no result, unreplayable —
|
||||
// gets the same warning with a reason that is simply false for it, and a message lying about its
|
||||
// own cause is what D39.93 п.2 forbids. Both branches leave the operator with the same action, so
|
||||
// naming both costs nothing and claiming one costs the truth.
|
||||
r.Log.WarnContext(ctx, "terminology: this book had already paid for the bank role BEFORE this run, and this run bought the pass again — the earlier calls' checkpoints could not be reused, either because the batch composition changed (the already-banked filter does not reproduce it) or because they recorded money with no result and cannot be replayed. This is a ONE-TIME cost; every later run replays for $0",
|
||||
logKeyReconsolidated, true,
|
||||
"book", r.Book.BookID, "skipped", res.BankSettled, "of_candidates", res.Candidates, "cost_usd", fmt.Sprintf("%.6f", run.costUSD))
|
||||
}
|
||||
|
|
@ -484,8 +490,8 @@ func (r *Runner) runTerminologist(ctx context.Context, snapID string, cands []te
|
|||
out := map[string]string{}
|
||||
res.Conf = map[string]int{}
|
||||
for i, b := range batches {
|
||||
if i >= run.attempted {
|
||||
break // the budget or a ceiling stopped the pass here; runBankRoleBatches already said so
|
||||
if !run.ran[i] {
|
||||
continue // this batch was never called; runBankRoleBatches already said why
|
||||
}
|
||||
if run.texts[i] == "" {
|
||||
// An EMPTY completion on a call that was actually made. It used to `continue` in silence, which is
|
||||
|
|
@ -664,8 +670,8 @@ func (r *Runner) runClassifier(ctx context.Context, snapID string, cands []termi
|
|||
types, genders = map[string]string{}, map[string]string{}
|
||||
bad, badGender, noGender := 0, 0, 0
|
||||
for i, b := range batches {
|
||||
if i >= run.attempted {
|
||||
break // the budget stopped the pass here, and it already said so
|
||||
if !run.ran[i] {
|
||||
continue // this batch was never called, and the pass already said why
|
||||
}
|
||||
if run.texts[i] == "" {
|
||||
// The same silence the render phase carried: a paid classify batch that returned NOTHING left every
|
||||
|
|
@ -781,10 +787,15 @@ func (r *Runner) bankCallEstimateUSD(st config.Stage, msgs []llm.Message) float6
|
|||
// request-hash axis — the SAME identity runBankAttempt will address (attemptRequest), never a hand-rebuilt
|
||||
// copy of it: this probe gates the role sub-budget, so an identity that drifts from the attempt's turns the
|
||||
// gate off (see attemptRequest).
|
||||
//
|
||||
// ⛔ A BURNED CHECKPOINT IS NOT A PAID BATCH. One that records money and no result cannot be replayed,
|
||||
// so runAttempt walks past it and buys the batch again — and a probe answering «already paid» would let
|
||||
// that purchase escape the role sub-budget entirely, which is the one thing this gate exists to bound.
|
||||
// The key can only hold such a row because cut calls are now settled there; before that, «a checkpoint
|
||||
// exists» and «this batch is done» were the same statement.
|
||||
func (r *Runner) bankCheckpointExists(st config.Stage, snapID string, ch chunk.Chunk, msgs []llm.Message) (bool, error) {
|
||||
_, maxTokens := r.bankCallBudget(st.Model, msgs)
|
||||
cp, err := r.Store.GetCheckpoint(RequestHash(r.attemptRequest(st, st.Model, snapID, ch, 0, maxTokens, msgs)))
|
||||
return cp != nil, err
|
||||
return r.paidAfterBurns(st, st.Model, snapID, ch, 0, maxTokens, msgs)
|
||||
}
|
||||
|
||||
// runBankAttempt performs ONE bank-role call on the shared money path: reserve → call → settle+checkpoint,
|
||||
|
|
@ -869,10 +880,15 @@ type bankRolePlan struct {
|
|||
// budget cut or that failed soft), plus the cost accounting for the report.
|
||||
type bankRoleRun struct {
|
||||
texts []string
|
||||
// attempted is how many batches the pass actually reached before a budget ceiling or a soft denial stopped
|
||||
// it. Without it "" is ambiguous — an EMPTY completion the run paid for and a batch never called look the
|
||||
// same — and the caller cannot warn about the first without crying wolf about the second.
|
||||
attempted int
|
||||
// ran says, PER BATCH, whether the pass actually reached it. Without it "" is ambiguous — an EMPTY
|
||||
// completion the run paid for and a batch never called look the same — and the caller cannot warn
|
||||
// about the first without crying wolf about the second.
|
||||
//
|
||||
// ⛔ IT CANNOT BE A COUNT. Admission is not a prefix: an already-paid batch costs nothing and is
|
||||
// admitted whatever the budget says, so a batch the budget refuses can sit BEFORE batches that are
|
||||
// free to serve. A count answers «how many from the start», which silently drops every paid batch
|
||||
// behind the first unaffordable one — their replay was $0 and their result was already bought.
|
||||
ran []bool
|
||||
estimateUSD float64
|
||||
costUSD float64
|
||||
cumUSD float64
|
||||
|
|
@ -889,7 +905,7 @@ type bankRoleRun struct {
|
|||
// a budget ceiling or a soft denial stops the pass and leaves the remaining terms untouched — the run never
|
||||
// aborts on this optional step. logKind names the phase (render|classify) in the logs.
|
||||
func (r *Runner) runBankRoleBatches(ctx context.Context, snapID string, plan bankRolePlan, batches [][]terminology.Candidate, logKind string) (bankRoleRun, error) {
|
||||
run := bankRoleRun{texts: make([]string, len(batches))}
|
||||
run := bankRoleRun{texts: make([]string, len(batches)), ran: make([]bool, len(batches))}
|
||||
// ONE stage for the whole pass — the estimate, the checkpoint probe and the attempt all read it, so the
|
||||
// three can never be sized against different knobs (the estimate is the reservation's own upper bound).
|
||||
st := r.bankStage(plan.role)
|
||||
|
|
@ -933,17 +949,22 @@ func (r *Runner) runBankRoleBatches(ctx context.Context, snapID string, plan ban
|
|||
//
|
||||
// So: decide up front, say what was cut, and run only that. Already-paid batches cost nothing and are
|
||||
// admitted regardless — holding them back would save no money and lose their result.
|
||||
// ⛔ ADMISSION IS PER BATCH, NOT A PREFIX. An already-paid batch costs nothing, so refusing one
|
||||
// unaffordable batch must not take the paid batches BEHIND it: their replay is $0 and their result is
|
||||
// already bought, and dropping them buys nothing while losing a consolidated bank. The loop used to
|
||||
// `break` here and hand the consumer a prefix bound, which made the comment above («admitted
|
||||
// regardless») false for every paid batch that happened to sit after a refused one.
|
||||
fits, plannedUSD := 0, 0.0
|
||||
probe := spent
|
||||
paidBatch := make([]bool, len(batches))
|
||||
admit := make([]bool, len(batches))
|
||||
for i := range batches {
|
||||
ch := chunk.Chunk{Chapter: 0, ChunkIdx: i}
|
||||
paid, perr := r.bankCheckpointExists(st, snapID, ch, msgsPer[i])
|
||||
if perr != nil {
|
||||
return run, perr
|
||||
}
|
||||
paidBatch[i] = paid
|
||||
if paid {
|
||||
admit[i] = true
|
||||
fits++
|
||||
continue
|
||||
}
|
||||
|
|
@ -951,8 +972,9 @@ func (r *Runner) runBankRoleBatches(ctx context.Context, snapID string, plan ban
|
|||
// the conservative side and the config number is the bound it looks like.
|
||||
want := r.bankCallEstimateUSD(st, msgsPer[i])
|
||||
if probe+want > plan.budgetUSD {
|
||||
break
|
||||
continue // this one cannot be afforded; the ones after it may still be free
|
||||
}
|
||||
admit[i] = true
|
||||
probe += want
|
||||
plannedUSD += want
|
||||
fits++
|
||||
|
|
@ -967,7 +989,10 @@ func (r *Runner) runBankRoleBatches(ctx context.Context, snapID string, plan ban
|
|||
"estimate_usd", fmt.Sprintf("%.6f", run.estimateUSD))
|
||||
}
|
||||
run.planned, run.dropped = len(batches), len(batches)-fits
|
||||
for i := 0; i < fits; i++ {
|
||||
for i := range batches {
|
||||
if !admit[i] {
|
||||
continue
|
||||
}
|
||||
// The synthetic chunk addresses the batch: chapter 0 is the BOOK level (no real chapter is 0), and the
|
||||
// batch ordinal is the chunk index, so two batches can never collide on one checkpoint.
|
||||
ch := chunk.Chunk{Chapter: 0, ChunkIdx: i}
|
||||
|
|
@ -986,7 +1011,7 @@ func (r *Runner) runBankRoleBatches(ctx context.Context, snapID string, plan ban
|
|||
run.fresh = run.fresh || att.freshCall
|
||||
spent += att.runCost
|
||||
run.texts[i] = att.text
|
||||
run.attempted = i + 1
|
||||
run.ran[i] = true
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
#
|
||||
# <dynamic> stands for a message built at run time — the site is pinned, the words cannot be.
|
||||
# The operator messages internal/pipeline emits without stopping the run — one line per call site,
|
||||
# `file<TAB>function<TAB>quoted message`, sorted. Pinned by TestEveryOperatorMessageIsCatalogued, whose
|
||||
# comment holds the boundary (what is covered, what is deliberately not, and why a catalogue rather than
|
||||
# more substring asserts).
|
||||
#
|
||||
# ⚠ THIS FILE IS NOT REGENERATED. A wording change is meant to arrive here as a one-line diff a reviewer
|
||||
# reads against the code it now describes: paste the line the failure prints, and keep the file sorted.
|
||||
# <dynamic> stands for a message built at run time — the site is pinned, the words cannot be.
|
||||
# ⚠ THIS FILE IS NOT REGENERATED. A wording change is meant to arrive here as a one-line diff a reviewer
|
||||
bankexport.go exportBank "bank export: could not marshal the bank; the export artifact was NOT refreshed and may be stale"
|
||||
bankexport.go exportBank "bank export: could not read the bank; the export artifact was NOT refreshed and may be stale"
|
||||
bankexport.go exportBank "bank export: could not write the export artifact; it was NOT refreshed and may be stale"
|
||||
|
|
@ -20,6 +20,9 @@ bookbuild.go staleUnits "build: the stale check could not be made for every ship
|
|||
bookbuild.go staleUnits "build: the stale check could not run; whether the source moved under the shipped rows is UNKNOWN (reported as unknown, not as none)"
|
||||
bookrun.go translateBook "the run ended before its VOLUME grant was used up — the stop below is NOT the volume ceiling"
|
||||
chunkrun.go reportEvicted "memory: the injection token budget DROPPED bank rows before the model saw them — these terms had no canon on the wire for those units"
|
||||
cutcall.go recordCancelledStage "could not mark the stopped position; its money is recorded but the chunk will read as never started"
|
||||
cutcall.go recordCancelledStage "the run was stopped over a call that had already gone out; the position is marked cancelled and the resume re-does it on the same budget"
|
||||
cutcall.go settleCutCall "we cut a delivered call; the reservation estimate is charged as an ESTIMATE only when the provider had acknowledged it with a reply"
|
||||
escalation.go maybeEscalate "escalation hop denied by a USD ceiling; keeping the primary flag"
|
||||
escalation.go maybeEscalate "stage escalated to a fallback model"
|
||||
events.go beginRunEvents "could not read the stored dispositions for the run-event counters; this run publishes no progress (the run continues; resync channel: `tmctl status --json`)"
|
||||
|
|
@ -92,6 +95,7 @@ status.go Status "config-drift not checked: the bank could not be folded, so dri
|
|||
status.go Status "config-drift not checked: the rows carry more than one snapshot within a wave (snapshot_drift), so config drift is UNKNOWN, not none"
|
||||
status.go Status "re-bill projection failed; the re-payment cost of the drift is unknown (reported as unknown, not as none)"
|
||||
status.go Status "status: no bank could be materialized; the unsigned-term count is unknown, not zero"
|
||||
status.go Status "status: the estimated share of the committed spend could not be read; it is UNKNOWN, not zero"
|
||||
status.go Status "the bank fold refused, so the projections below are computed against the glossary the LAST run stored — they are a fact about the past, not a projection of the next run"
|
||||
terminologist.go glossaryRows "terminology: could not read the bank — whatever this call feeds goes silent (the canon anchor, the bank conflict check, or both), and its zero then means «not asked» rather than «nothing found»"
|
||||
terminologist.go loadTargetScript "the banknote channel is on but no gates.terminology.target_script is declared: draft-side proposals are NOT screened for the answer language, so a rendering in another script can enter the signature map and the auto-bank"
|
||||
|
|
@ -112,7 +116,7 @@ terminologist.go runTerminologist "terminology: some families were NOT co-batche
|
|||
terminologist.go runTerminologist "terminology: the TYPE classifier ended short — its OWN budget cut a pass, so some candidates keep the draft heuristic type; the bank's renderings are unaffected and this alone does not make the bank partially consolidated"
|
||||
terminologist.go runTerminologist "terminology: the model answered in another script; those lines are REFUSED (the terms stay unconsolidated) — a book with few signed rows gives the model no target-language anchor"
|
||||
terminologist.go runTerminologist "terminology: this bank is PARTIALLY consolidated — a budget cut the RENDER pass, so some terms were never offered to the role at all; `unanswered` below counts them together with terms the role saw and did not answer"
|
||||
terminologist.go runTerminologist "terminology: this book had already paid for the bank role BEFORE this run, and this run bought the pass again — those earlier calls were made under a batch composition the already-banked filter does not reproduce, so their checkpoints could not be found. This is a ONE-TIME cost; every later run replays for $0"
|
||||
terminologist.go runTerminologist "terminology: this book had already paid for the bank role BEFORE this run, and this run bought the pass again — the earlier calls' checkpoints could not be reused, either because the batch composition changed (the already-banked filter does not reproduce it) or because they recorded money with no result and cannot be replayed. This is a ONE-TIME cost; every later run replays for $0"
|
||||
volume.go deliveredUnits <dynamic>
|
||||
volume.go planVolume "a VOLUME ceiling on a book that MINES its bank: each purchase drafts more, so the bank-mining stop writes a larger auto-bank, and the next purchase moves the edit-wave snapshot the previous purchase's edit jobs are pinned to. Expect that run to need --resnapshot and to re-pay the units the new terms actually touch (the re-payment consent gate still bounds it)"
|
||||
volume.go rescopeEditWave "the bank moved between planning and the edit wave, so units judged FREE are no longer free: they have been re-judged against the snapshot the edit wave actually uses"
|
||||
|
|
|
|||
|
|
@ -818,6 +818,18 @@ func (r *Runner) rowsResumeFree(rows []store.ChunkStatus, draftNames, editNames
|
|||
if cs.SnapshotID != cur && !r.repinnable(cs.SnapshotID, cur, w) {
|
||||
return false
|
||||
}
|
||||
// ⛔ THE SAME QUESTION runStage ASKS, ASKED THROUGH THE SAME PREDICATE. A `cancelled` row records
|
||||
// a stop, not an answer: the resume re-does that call, for money. Read as free, a unit whose last
|
||||
// row is one needed no slot — so a grant of one unit paid for two and the volume report, which
|
||||
// speaks only when something was carried, said nothing at all.
|
||||
//
|
||||
// ⚠ AND IT IS ASKED LAST, AFTER the switch above has dropped the stages this pipeline no longer
|
||||
// runs. Asked first — where it was written — it made a unit PAID for a cancelled row of a stage
|
||||
// that will never be called again, which is the same error in the opposite direction: an operator
|
||||
// who edits the pipeline over a stopped run would spend volume slots on nothing.
|
||||
if !resolvedForResume(&cs) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
349
docs/PROGRESS.md
349
docs/PROGRESS.md
|
|
@ -476,7 +476,7 @@ post-header и оплачены. Три ряда девятки «до заго
|
|||
| поля утверждены двусторонне | да — `WhitespaceOnly`, `Delivered`, `estimated`, оба условных сообщения |
|
||||
| тест-тождество §4.4 пинит формулу, не константу | да — запрещённый набор ВЫВОДИТСЯ из формулы: «banned set [238 239 240 899 900 901] → 0 hit(s); control → 2 hit(s)» |
|
||||
| числа сняты ПОСЛЕ последней правки, со счётом скипов | да — секция «ЧИСЛА» выше |
|
||||
| таблица мутаций ПОЛНАЯ, выжившие названы поимённо | да — 114 посадок, **выживших НЕТ**, свип 0 из 325. ⚠ Выжившие БЫЛИ и названы поимённо: `CUTCALL-the-redo-doubles-the-budget` (пин перестал держать верное свойство), `CUTBANK-a-burned-batch-counts-as-paid` и `CUTDEADLINE-the-h2-write-bound-is-removed` (оба — мои вакуумные пины). Все три закрыты и пере-проверены поштучной посадкой |
|
||||
| таблица мутаций ПОЛНАЯ, выжившие названы поимённо | да — 114 посадок, **выживших НЕТ**, свип 0 из 325. ⚠ Выжившие БЫЛИ и названы поимённо: `CUTCALL-the-redo-doubles-the-budget` (пин перестал держать верное свойство), `CUTBANK-a-burned-batch-counts-as-paid` и `CUTDEADLINE-the-h2-write-bound-is-removed` (оба — мои вакуумные пины). Все три закрыты и пере-проверены поштучной посадкой. ⟨Первых двух в дереве БОЛЬШЕ НЕТ: `CUTDEADLINE-…` снята вместе с бондом 10.09, `CUTBANK-…` — когда банковый зонд перешёл на общее определение. Имена — хроника⟩ |
|
||||
| дифф `^func Test` снят ИСПОЛНЕНИЕМ, прибор назван | да — секция «Дифф `^func Test`» |
|
||||
| всё живое — в ДЕРЕВЕ, а не в письме | да — 23 пути, все в зоне (`backend/` + своя секция `docs/PROGRESS.md`); вне зоны 0; индекс пуст; `books` не тронуты |
|
||||
|
||||
|
|
@ -661,23 +661,349 @@ retry-loop 6→3 · bank-and-counters 6→6 · transport-and-config 9→7
|
|||
Восстановленный файл — снова в скратчпаде, то есть снова в tmpfs; **воспроизводится из журнала за один
|
||||
прогон, отдельного хранения не требует.**
|
||||
|
||||
#### ⚠ ДИСПОЗИЦИИ ОРКЕСТРАТОРА `a8` — НОСИТЕЛЯ В РЕПОЗИТОРИИ НЕ ИМЕЮТ
|
||||
#### ДИСПОЗИЦИИ ОРКЕСТРАТОРА, ЖИВШИЕ ТОЛЬКО В КАНАЛЕ — ✅ ЗАКРЫТО В ТОТ ЖЕ ДЕНЬ, `D39.231`
|
||||
|
||||
Оркестратор смены №23 (`textmachine-a8`) перед смертью сессии закрыл три пинга из четырёх. Его слово
|
||||
жило в межсессионных сообщениях, то есть нигде: **сессия мертва, канала нет.** Записываю сюда дословно по
|
||||
смыслу, чтобы следующий оркестратор не решал это заново, — и отмечаю, что ратификации в журнале решений у
|
||||
этих трёх НЕТ.
|
||||
Оркестратор смены №23 закрыл три пинга из четырёх **межсессионными сообщениями**. Его слово жило в
|
||||
канале, то есть нигде: канал лежит в tmpfs и умер вместе с окружением. Записала сюда дословно по смыслу,
|
||||
чтобы следующий оркестратор не решал это заново, и отдельно объявила, что носителя в репозитории у этих
|
||||
трёх НЕТ.
|
||||
|
||||
⛔ **ЭТО ЗАКРЫТО В ТОТ ЖЕ ДЕНЬ — `D39.231`, коммит `aad50a7`, и там же усилена НОРМА:** диспозиция
|
||||
оркестратора по пингу сессии ратифицируется ИЛИ получает строку носителя **тем же движением, каким
|
||||
отправляется ответ**; ответ в канале — уведомление о решении, а не само решение. Ниже три пункта
|
||||
оставлены как были записаны, к каждому добавлен носитель.
|
||||
|
||||
⚠ **И моя собственная ошибка вывода, снимаю сама:** я написала «сессия мертва» и «пункты без владельца»,
|
||||
прочитав исчезновение ИМЕНИ `textmachine-a8` из `ListAgents` как смерть смены. Умерло имя — рестарт
|
||||
окружения его не переживает; **смена №23 и её контекст целы**, тот же оркестратор работает под именем
|
||||
`textmachine-11`. Отсутствие в списке имён не есть отсутствие сессии — тот же класс, что «ноль строк в
|
||||
выдаче» против «прибор не спросил существующее».
|
||||
|
||||
1. **h2-write-бонд (`transport-and-config#6`, `#9`) — УДАЛЯТЬ.** Принял мой довод против собственного
|
||||
заказа: механизм рвёт ВСЮ h2-связь, а не застрявший стрим, и для тел, которые движок реально шлёт,
|
||||
выстрелить не может (замер разводит обещание и предмет на два порядка). Сослался на `D39.216` —
|
||||
«где форма не тянет, её не подпирают»; носитель класса остаётся строкой бэклога **373**.
|
||||
✅ Ратифицировано `D39.231` п.2.
|
||||
2. **Вердикт ГЛАВЫ по остановленной позиции (`критик#3`) — забрал СЕБЕ**, продуктовый вопрос владельцу.
|
||||
✅ Заведён строкой бэклога **374** (`D39.231` п.3) — больше не зависит от того, жива ли чья-то сессия.
|
||||
3. **`memberDrops` читает `cancelled` как выпавшего члена (`критик#4`) — забрал СЕБЕ**, семантика экспорта.
|
||||
✅ Заведён строкой бэклога **375** (`D39.231` п.3).
|
||||
|
||||
⇒ **Пункты 2 и 3 сейчас без владельца:** сессия, обещавшая отнести их владельцу, не существует.
|
||||
⚠ Обе строки, 374 и 375, **исчезнут сами, если денежную половину пака урежут**: они следствия класса
|
||||
`cancelled`, а не самостоятельные дефекты.
|
||||
|
||||
#### Вопрос владельцу и оркестратору — один, и он прежний
|
||||
#### 10.09 — h2-WRITE-БОНД УДАЛЁН (ратификация `D39.231` п.2). Отдельное движение, наряда НЕ касается
|
||||
|
||||
Оркестратор велел снять бонд СЕЙЧАС, не дожидаясь вердикта о останове: предмет независим (бонд — про
|
||||
таймаут записи на транспорте, останов — про волю человека), ратификация уже есть, а удаление уменьшает
|
||||
поверхность, а не растит.
|
||||
|
||||
**Что ушло вместе с ним — полный список носителей, прибор назван.**
|
||||
Греп `WriteByteTimeout|h2WriteByteTimeout` по `backend/` (359 go-файлов прочтено) и по `docs/` (120 .md,
|
||||
без `prompts`/`reports`) дал ЧЕТЫРЕ носителя в коде и ни одного скрытого:
|
||||
|
||||
| носитель | что сделано |
|
||||
|---|---|
|
||||
| `internal/llm/httpllm.go` — константа `h2WriteByteTimeout` и её 15-строчный комментарий | снята |
|
||||
| `internal/llm/httpllm.go` — `h2.WriteByteTimeout = …` в `tuneHTTP2` | снята; доккомментарий «installs the three liveness bounds» → «the keepalive pair» |
|
||||
| `internal/llm/attemptcut_test.go` — пин `TestTheWriteSideKeepaliveIsDerivedFromTheReadSideOne` ⟨имени в дереве нет: это ПРЕЖНЕЕ имя⟩ | **переписан, не удалён** — см. ниже |
|
||||
| `cmd/tmmutate/mutations.json` — `CUTDEADLINE-the-h2-write-bound-is-removed` | снята; заведена замена |
|
||||
|
||||
⚠ **Пин держал НЕ ТОЛЬКО бонд, и это единственное место, где удаление было не механическим.** Из четырёх
|
||||
его утверждений три — про бонд (выведенность из read-side пары, ненулевость, установленность на
|
||||
транспорте) и умирают вместе с предметом. Четвёртое — что `ReadIdleTimeout`/`PingTimeout` стоят на
|
||||
транспорте, который клиент СТРОИТ, — самостоятельная гарантия, и она бы утекла при простом удалении
|
||||
файла-теста. ⇒ тест переписан под неё: `TestTheKeepalivePairSitsOnTheTransportTheCloudClientBuilds`,
|
||||
контроль на ненулевые константы поднят в начало (иначе сравнение нуля с нулём проходит на голом
|
||||
транспорте). **Объявляю по `D39.183`:** правка теста вызвана ЗАКАЗАННОЙ сменой поведения, а не желанием
|
||||
зелени; гарантия про бонд снята вместе с бондом, гарантия про keepalive-пару сохранена под новым именем.
|
||||
|
||||
⛔ **ПОПРАВКА 10.09, вечер: абзац выше был НЕВЕРЕН в момент написания, снимаю сама.** Гарантия НЕ была
|
||||
сохранена. Тело нового пина брало СВЕЖИЙ клон (`tuneHTTP2(http.DefaultTransport…Clone())`) и утверждало
|
||||
про него — то есть про транспорт, которым никто не пользуется. Снятие `tuneHTTP2` из боевой конструкции
|
||||
переживало и пин, и весь пакет; замер круга: 0 из 220 тест-файлов строили облачный клиент и читали его
|
||||
h2. Это ТОТ ЖЕ дефект, который наряд записал про СТАРЫЙ пин (`transport-and-config#1`), воспроизведённый
|
||||
мной в замене. Починено: `keepAliveHTTPClient` разделён на себя и `buildCloudClient`, отдающую клиент И
|
||||
транспорт, на котором поставила границы; пин читает её. Мутация
|
||||
`CUTKEEPALIVE-the-cloud-client-is-not-tuned-at-all` — **RED** текстом «the cloud client could not be
|
||||
configured for h2 — this test measured nothing».
|
||||
|
||||
⚠ **Каталог: 325 → 325.** Снятая запись была ЕДИНСТВЕННОЙ, сажавшей мутацию в `tuneHTTP2`; простое
|
||||
удаление оставило бы пере-писанный пин без носителя в каталоге. Заведена
|
||||
`CUTKEEPALIVE-the-read-idle-bound-is-not-installed` (`battery: true`), которая сносит установку
|
||||
`ReadIdleTimeout`. **Это добавление, а не удаление — если оркестратор считает его выходом за рамки
|
||||
движения, оно снимается одной строкой.**
|
||||
|
||||
**ПРЕДЪЯВЛЕНИЕ МУТАЦИЕЙ — три замера на КОПИИ дерева** (`rsync` без `.env*` и `bin/`; контроль копии:
|
||||
файлов `.env*` внутри **0**, go-файлов скопировано **359**):
|
||||
|
||||
1. **Дерево БЕЗ бонда** (как сейчас) → `./internal/llm/` **ok**. Удаление ничего не уронило.
|
||||
2. **Бонд ВОЗВРАЩЁН в копию** (константа + установка) → `./internal/llm/` **ok**. Ничего не пинит и его
|
||||
отсутствие. ⇒ бонд был для батареи НЕВИДИМ в обе стороны: он не держал ничего.
|
||||
3. ⛔ **КОНТРОЛЬ, без которого два зелёных выше означали бы «прибор не спросил»:** посадка новой мутации
|
||||
тем же инструментом → **RED**, и засчитана ПО ТЕКСТУ, а не по цвету:
|
||||
`attemptcut_test.go:852: the read-idle bound is not on the transport: got 0s want 15s`.
|
||||
Тот же пакет, тот же прогон, тот же пин — краснеть он умеет.
|
||||
|
||||
**Строка бэклога 373 остаётся** носителем класса «наш дедлайн инертен на h2» (`D39.231` п.2 прямо это
|
||||
говорит): удалён МЕХАНИЗМ, который класс не закрывал, а не сам класс.
|
||||
|
||||
⚠ Прежние записи этой секции про `WriteByteTimeout` (числа §4.4 и находка `transport-and-config#1`)
|
||||
оставлены как были: они верны на момент, когда писались, и заменяются этой строкой, а не переписыванием.
|
||||
|
||||
#### 10.09 — ДВА ПРЕДГЕЙТА НАУЧЕНЫ ЧИТАТЬ ОЖОГ. Заказ оркестратора, отдельное движение
|
||||
|
||||
⚠ **Поправка к заказу, и она о весе наряда, а не о работе.** Оркестратор передал это как находку
|
||||
консилиума со словами «этого не видела ни одна линза наряда». Замер: наряд несёт её ТРЕМЯ позициями,
|
||||
все P0 — `burn-walk#2` (ремонт), `burn-walk#3` (хоп) и их дубль `bank-and-counters#3`, где прямо
|
||||
записано «два ДРУГИХ зонда про ожог не знают». То есть независимый вердикт ЧТЕНИЕМ пере-открыл то, что
|
||||
линза нашла исполнением. Для решения о судьбе остальных 29 это улика: наряд и консилиум сошлись на
|
||||
одном месте, придя к нему разными путями.
|
||||
|
||||
**Предмет.** Контракт «чекпойнт есть ⇒ отвечено и оплачено» спрашивают ТРИ предгейта до воронки.
|
||||
Сожжённый ключ — деньги без результата: воронка его воспроизвести не может, она шагает мимо и покупает
|
||||
работу заново. Предгейт, читающий такую строку как «уже оплачено», поэтому НЕ пропускает покупку — он её
|
||||
ВЫПУСКАЕТ, мимо той единственной проверки, которую сам же и охраняет.
|
||||
|
||||
| предгейт | было | стало |
|
||||
|---|---|---|
|
||||
| `terminologist.go:793` (банк) | `cp != nil && !burnedByCut(cp)` | учён ещё в паке |
|
||||
| `repair.go:374` | `cp != nil` | `cp != nil && !burnedByCut(cp)` |
|
||||
| `escalation.go:152` | `fbExists != nil` | `fbExists != nil && !burnedByCut(fbExists)` |
|
||||
|
||||
⛔ **ВОСПРОИЗВЕДЕНО ИСПОЛНЕНИЕМ ДО ПОЧИНКИ — следствие, а не совпадение строк.** Оркестратор передал
|
||||
следствие как выведенное чтением и просил проверить. Обе фикстуры написаны первыми и на непочиненном
|
||||
дереве упали ТЕКСТОМ ПРО ДЕНЬГИ:
|
||||
- ремонт: `want 2 repair calls, got 3 — one of them was bought outside gates.repair.budget_usd`;
|
||||
- эскалация: `want 2 hop calls, got 3` (мимо `escalation.budget_usd` И мимо `escMu`).
|
||||
|
||||
**Четыре фикстуры, парами.** Каждая правка закреплена ДВУМЯ: одна держит свойство (сожжённый ключ не
|
||||
покупает), вторая — его противоположность (настоящий ключ по-прежнему воспроизводится бесплатно на
|
||||
исчерпанном бюджете). Без второй предикат, отвечающий «не оплачено» на всё, прошёл бы первую и тихо
|
||||
выключил контракт бесплатного резюма, который `escalation.go` обещает в своём же доккомментарии.
|
||||
|
||||
⚠ **Как строится ожог — единственное тонкое место, и первая редакция была ФЛЕЙКОВОЙ.** Наш денежный
|
||||
предикат книжит оборванный вызов только после подтверждения провайдером, поэтому сервер, который просто
|
||||
держит молчащее соединение, даёт строку на $0 — настоящую, но не ту. Первая редакция слала заголовки,
|
||||
флашила и внешним сигналом отменяла прогон. Замер: **2 прогона из 5** отменялись раньше, чем клиент
|
||||
разобрал заголовки, вызов читался как неотвеченный и книжился в ноль — то есть фикстура была бы зелёной,
|
||||
не измеряя ничего. Заменено на обрыв, которым правит СЕРВЕР (`connection_lost` жжёт ключ ровно так же и
|
||||
внешней синхронизации не требует): 8 прогонов из 8 одинаковы.
|
||||
|
||||
**Мутации — по одной на правку, засчитаны ПО ТЕКСТУ:**
|
||||
`CUTBURN-the-repair-pregate-forgets-the-burn` и `CUTBURN-the-escalation-pregate-forgets-the-burn`,
|
||||
⟨обе ПОЗЖЕ СНЯТЫ из каталога тем же днём: когда зонд стал ОДНИМ определением, их предмет — по-строчная
|
||||
правка на каждой площадке — перестал существовать, и их место заняли пять записей семьи. Имена оставлены
|
||||
здесь как хроника, в дереве их нет⟩
|
||||
обе `battery: true`, обе **RED** своим тестом, и текст падения называет свежий вызов, а не транспорт.
|
||||
Каталог 325 → 327, подмножество battery 114 → 116.
|
||||
|
||||
**Знаменатель класса — прибор спросил существующее.** `GetCheckpoint` живёт в **10** местах живого кода
|
||||
(прочитано 360 go-файлов): 4 — вопрос «оплачено ли» (три предгейта + воронка `stagerun.go:492`), все
|
||||
учены; 6 читают `cs.FinalHash`, то есть указатель на АВТОРИТЕТНЫЙ ответ, а сожжённая строка им стать не
|
||||
может — путь обрыва пишет `FinalHash: ""` и флагует позицию. ⇒ **четвёртого необученного читателя нет**,
|
||||
строка бэклога по правилу остановки не понадобилась.
|
||||
|
||||
⚠ Правило остановки соблюдено: движение — два предиката и их пины, больше ничего. Наряд не начат.
|
||||
|
||||
#### ⛔ 10.09 — АДВЕРСАРИАЛЬНЫЙ КРУГ ПО СВОЕМУ ЖЕ ДВИЖЕНИЮ: МОЯ ПОЧИНКА БЫЛА РАЗМЕНОМ
|
||||
|
||||
Круг: 4 направленные линзы, у каждой свой опровергатель, плюс критик полноты. **Заявлено 20, пережило
|
||||
опровержение 10, плюс 2 у критика.** Главная находка — про починку, которую я сдала часом раньше как
|
||||
сделанную, при зелёной батарее и красных мутациях.
|
||||
|
||||
⛔ **ЧТО БЫЛО НЕ ТАК.** Оба зонда — и мои два, и банковский, с которого я их писала, — спрашивали
|
||||
**фиксированный индекс попытки**. Воронка на ожоге не останавливается: она уходит на следующий индекс при
|
||||
том же бюджете и покупает ТАМ. Значит после оборванного прогона позиция читается «attempt 0 сожжён,
|
||||
attempt 1 ОПЛАЧЕН и отвечен». Зонд, смотрящий на стартовый ключ, видит ожог, отвечает «не оплачено» — и
|
||||
вызывающий, не найдя бюджета, **выбрасывает уже купленный перевод**. Навсегда, на каждом резюме.
|
||||
|
||||
**Доказано откатом ОДНОЙ строки на копии, не рассуждением:**
|
||||
|
||||
| дерево | «ожог не покупает» | «оплаченное за ожогом реплеится» |
|
||||
|---|---|---|
|
||||
| ДО моей починки | **FAIL** | **PASS** |
|
||||
| ПОСЛЕ моей починки | **PASS** | **FAIL** |
|
||||
|
||||
⇒ два дефекта, один бит, противоположные стороны. **Третий раз за смену эта форма** (раньше — $0.80 на
|
||||
отказах провайдера).
|
||||
|
||||
**Починка правильной формы: чинить ВОПРОС, а не ответы.** Одно определение `paidAfterBurns`
|
||||
(`internal/pipeline/cutcall.go:196`) шагает ожоги ровно как воронка и отвечает про тот ключ, который
|
||||
воронка возьмёт. Три площадки зовут его: `escalation.go:141` · `repair.go:373` · `terminologist.go:792`.
|
||||
Два вопроса, заданные одним способом, разойтись не могут — в этом смысл функции против трёх площадок,
|
||||
каждая из которых «шагает правильно» (`D39.216` п.3б).
|
||||
|
||||
⚠ **Третий член семьи был поражён тем же корнем, и это код ПАКА, а не сегодняшнего движения:** банковский
|
||||
зонд, тот самый «образец». Идёт в наряд НОВОЙ позицией.
|
||||
|
||||
**Семья предъявлена целиком — три члена, обе стороны бита, шесть пинов:**
|
||||
|
||||
| член | «ожог не покупает» | «оплаченное за ожогом реплеится» |
|
||||
|---|---|---|
|
||||
| `escalation.go:141` | `TestABurnedHopKeyDoesNotBuyAHopOutsideTheEscalationBudget` | `TestAPaidHopBehindABurnedKeyStillReplaysFree` |
|
||||
| `repair.go:373` | `TestABurnedRepairKeyDoesNotBuyARepairOutsideTheSubBudget` | `TestAPaidRepairBehindABurnedKeyStillReplaysFree` |
|
||||
| `terminologist.go:792` | `TestTheBankPaidProbeSeesThroughABurnedCheckpoint` | `TestTheBankProbeFindsThePaidBatchBehindABurnedKey` |
|
||||
|
||||
**Каталог: 3 протухших якоря сняты, 6 заведено** (327 → 330, battery 116 → 119). Две на сам контракт
|
||||
(`ignores-the-burn` краснит три «не покупает», `stops-at-the-first-key` — три «реплеится»), три на то,
|
||||
что каждая площадка им пользуется, одна на тюнинг боевого клиента. Все **RED по тексту**.
|
||||
|
||||
⚠ **Первая редакция двух записей каталога НИЧЕГО НЕ УТВЕРЖДАЛА:** список тестов через запятую там, где
|
||||
`-run` берёт регексп. Поймал инструмент, дословно: «the run filter … matched no test — it was renamed or
|
||||
removed, and this entry has been asserting nothing». Переписано альтернацией, пере-проверено посадкой.
|
||||
|
||||
**Остальные находки круга — все пять починены в этом же движении:**
|
||||
1. комментарий лока в `escalation.go` описывал прежний код и называл мёртвую переменную;
|
||||
2. обе Real-фикстуры не утверждали СВОЮ ГЛАВНУЮ ПРЕМИССУ («на исчерпанном бюджете») — с холостым
|
||||
exhaust-хелпером оставались зелёными, то есть держали не то, что обещали именем;
|
||||
3. burned-фикстура ремонта при сломанной премиссе обвиняла ПРЕДГЕЙТ в выпуске платного вызова мимо
|
||||
суб-бюджета — правый цвет, неправый текст, и следующая смена пошла бы чинить `repair.go`;
|
||||
4. мёртвые поля `arrived`/`once` — остаток снятой флейковой схемы, с доккомментарием о несуществующем
|
||||
назначении; ни `vet`, ни `gofmt` их не видят;
|
||||
5. пин keepalive не читал боевой клиент — см. поправку выше по тексту.
|
||||
|
||||
⚠ **И собственная ошибка замера, названная тут же:** проверяя пункт 2, я делала холостыми ОБА
|
||||
exhaust-хелпера, но патч ремонтного не применился из-за экранирования — ремонтные фикстуры прошли
|
||||
законно, и я чуть не записала «премисса не срабатывает». Пере-делала: все три падают текстом
|
||||
«premise broken: the repair sub-budget is NOT exhausted (spent 0.001063, budget 1.000000)».
|
||||
|
||||
⭐ **Что этот круг доказал про сам метод.** Знаменатель читателей был ВЕРЕН — их правда три. Дефект сидел
|
||||
не в их числе, а в ФОРМЕ ВОПРОСА, одинаковой у всех трёх, и никакой счёт читателей его не ловит. Ловит
|
||||
направленный второй читатель. ⇒ знаменатель закрывает одну ОСЬ, а не работу.
|
||||
|
||||
#### 10.09 — НАРЯД ОТРАБОТАН ЦЕЛИКОМ: 36 позиций, 34 сделано, 2 пингом
|
||||
|
||||
Слово владельца через оркестратора: доводить наряд своей рукой. Отработан целиком, по семьям, в порядке
|
||||
«деньги → числа → строки». **Единственный носитель исходов — сам наряд**
|
||||
(`docs/archive/reports/CUT_CALLS_DOFIX_WORK_ORDER_2026-09-08.md`, правится по зонному исключению в его
|
||||
шапке): у каждой позиции поля `исход` и `предъявлено`, и сверяются они машинно, а не глазами.
|
||||
|
||||
```
|
||||
позиций в наряде: 36 (P0=12 P1=12 P2=8 PING=4)
|
||||
ИСХОД НЕ ИЗ ТРЁХ: 0 · «сделано» БЕЗ поля «предъявлено»: 0
|
||||
ЗАКРЫТО 34 из 36 · ИТОГ: все позиции имеют исход и предъявление
|
||||
```
|
||||
|
||||
⛔ **Каждая семья закрыта ОДНИМ контрактом, а не пачкой правок** — это и есть ответ на «семь кругов не
|
||||
сходились». Знаменатель каждого класса посчитан командой, а не памятью:
|
||||
|
||||
| класс | контракт | закрыт |
|
||||
|---|---|---|
|
||||
| читатели денежного контракта | одно определение `paidAfterBurns` шагает ожоги ровно как воронка | **3 из 3** (`grep -c 'r.paidAfterBurns('` = 3) |
|
||||
| выходы цепочки `retryLoop` | `moreOwed` копит, `chainError` выносит, решение по ДЕНЬГАМ, а не по типу | **4 из 4** (все четыре `return` идут через него) |
|
||||
| носители `resolvedForResume` | один предикат на все вопросы «это уже ответ?» | **3 площадки + четвёртый спрашивающий через третью** (`projectBookUSD` → `resolveChunkState`) |
|
||||
| инвариант строки чанка | строка сходится с суммой чекпойнтов СВОЕЙ позиции на ЛЮБОМ выходе | **4 фикстуры** держат `assertRowsMatchTheLedger` |
|
||||
|
||||
**Что нашлось по ходу и чего в наряде не было:**
|
||||
1. **Моя же починка предгейтов оказалась РАЗМЕНОМ** — доказано откатом одной строки: до неё «оплаченное
|
||||
за ожогом реплеится» проходило, а «ожог не покупает» падало; после — ровно наоборот. Третий раз за
|
||||
смену один бит в две стороны.
|
||||
2. **Наряд ПРЕДСКАЗАЛ эту регрессию** позицией `bank-and-counters#2`: «зонд зашит на попытку 0, а обход
|
||||
ожога идёт по возрастающим индексам… зонд должен спрашивать ту же ось, что и обход». Я чинила по
|
||||
заказу письмом, не открыв наряд.
|
||||
3. **Знаменатель, посчитанный по именам функций, — не знаменатель.** «Пять глаголов со словом
|
||||
`Checkpoint`» превратилось в **12 функций**, когда прибор спросил ТАБЛИЦУ, а не словарь имён.
|
||||
4. **Мой пин инварианта был флейковым: 2 красных из 8 на мутанте** — отмена обгоняла разбор заголовков,
|
||||
обрыв выходил на $0, строка сходилась «ноль к нулю». Перестроен так, что деньги НЕИЗБЕЖНЫ.
|
||||
5. **Две мутации СНАЧАЛА ВЫЖИЛИ** (ремонтная и счётная) — носителей не было вовсе, и без посадки я бы
|
||||
этого не узнала.
|
||||
6. **Снята СВОЯ недостижимая ветка** в накопителе цепочки и **чужая недостижимая константа**
|
||||
`FlagConnectionLost` — обе выглядели стражами и не могли выстрелить.
|
||||
7. **Шесть чужих якорей каталога** протухли от моих правок (один разорван моим же комментарием) —
|
||||
пере-нацелены поштучно, каждый пере-проверен посадкой. Каталогизированный ВЫЖИВШИЙ с доводом «ветка
|
||||
недостижима» не тронут: это улика, а не протухший якорь.
|
||||
|
||||
**Заказанные смены поведения, объявляю отдельно (`D39.183`):**
|
||||
- голден операторских сообщений обновлён ОДНОЙ строкой (126 → 126) — формулировка причины повторной
|
||||
оплаты банка изменена заказанной правкой `круг8#1`;
|
||||
- пин write-бонда заменён пином keepalive-пары — предмет удалён ратификацией `D39.231` п.2;
|
||||
- поле `run.attempted` (счётчик) заменено на `run.ran []bool` — счётчик не может описать несплошное
|
||||
множество приёма партий.
|
||||
|
||||
#### ⛔ 10.09 — ОХОТНИК ОРКЕСТРАТОРА: ДВА ВЫЖИВШИХ МУТАНТА, ОБА НА ЛОЖНЫХ ПРЕДЪЯВЛЕНИЯХ
|
||||
|
||||
Верификатор «вне карты» по сданной работе. Блокеров нет, батарея и каталог у него сошлись с моими. Но две
|
||||
посадки пережили батарею, и обе — не новые предметы, а **утверждения о закрытии, которые не держались**.
|
||||
Обе воспроизвела своим прогоном, прежде чем классифицировать.
|
||||
|
||||
**1. Выход по отмене — ЛОЖНОЕ «предъявлено» позиции наряда.** Снятие `cancelledDuring` на ПЕРВОМ выходе
|
||||
(`httpllm.go:199`) оставляло батарею зелёной, при том что близнец на `:222` краснел. Позиции
|
||||
`money-predicate#1` и `bank-and-counters#4` утверждали «оба выхода идут через `chainError`» и называли
|
||||
пин, где стоит проверка `errors.Is(err, context.Canceled)` именно про этот выход.
|
||||
|
||||
⛔ **Механизм — класс, которого у нас не было: пин удовлетворялся ЧУЖОЙ уликой.** Фикстура гонит петлю
|
||||
ЧЕРЕЗ ПРОВОД, а там стоп приходит по вызову в полёте — значит ошибка самой попытки уже родительски-
|
||||
отменённый обрыв, несущий `context.Canceled` в поле `Parent`. Замер на мутанте: `isCanceled=true
|
||||
cause=connection_lost` в **6 прогонах из 6**. То есть `errors.As` находил обрыв ПЕРВОЙ попытки, а
|
||||
`errors.Is` — отмену внутри ВТОРОЙ. **Это не флейк, а детерминированная пустота: повторный прогон такое
|
||||
не ловит.** ⇒ на мутанте спрашивать надо не «покраснело ли», а ЧТО ИМЕННО удовлетворяло утверждение.
|
||||
|
||||
Починка: `TestTheStopExitStillReadsAsCancelledWhenTheAttemptDidNot` гонит `retryLoop` НАПРЯМУЮ — попытка
|
||||
падает обычной 503, стоп приходит на её возврате, и опереться не на что, кроме самого выхода. Провод
|
||||
такой порядок создать не может: окно между возвратом попытки и чтением контекста в несколько инструкций,
|
||||
и фикстура, гоняющаяся за ним, мерила бы планировщик. Заодно закрыта связка «новый тип ↔ код выхода»:
|
||||
`AttemptCutError` давал **0 хитов** в тестах `cmd/tmctl` при **26** вызовах отображения — заведён
|
||||
`TestTheCutErrorTypeKeepsItsExitCode` (пять форм + контроль «потолок старше обрыва»). Мутации
|
||||
`CUTCHAIN-the-stop-exit-drops-the-cancellation` и `CUTEXIT-a-stop-stops-mapping-to-five` — RED.
|
||||
|
||||
**2. Вендорская пара, из которой считается КАЖДЫЙ дедлайн — ложное предъявление §4.4 ПАКА.**
|
||||
`vendorHourlyTokenBudget` можно учетверить (128000 → 512000), и зелены и `internal/llm`, и
|
||||
`internal/config`, и вся батарея. Причина: `vendorSeconds` (тест) берёт ожидание из ТЕХ ЖЕ
|
||||
внутрипакетных констант, что и `deriveDeadline`, — порча двигает обе стороны, и тождество сходится.
|
||||
Пин держал РАСПОЛОЖЕНИЕ формулы и не держал ЧИСЛА, а §4.4 требовала неформальной исполнимости.
|
||||
|
||||
⚠ **Классификацию я сначала дала В СВОЮ ПОЛЬЗУ и тут же привела довод против себя.** Разбор всех 36
|
||||
полей «предъявлено» показал: пин деривации не назван ни одной позицией наряда ⇒ по букве правила
|
||||
остановки это строка бэклога. Но цена денежная и ровно про предмет пака — вчетверо короче дедлайн
|
||||
означает, что живые генерации становятся self-cut'ами, за которые движок теперь ПЛАТИТ, и пять
|
||||
провайдеров из восьми сидят на этом дефолте. Оркестратор пере-провёл СВОЮ границу («опровергает любое
|
||||
утверждение о закрытии, которое мы вот-вот ратифицируем, а не только позицию наряда») и вернул починку
|
||||
в круг.
|
||||
|
||||
Починка: `TestTheVendorsPublishedPairIsWhatTheVendorPublishes` цитирует вендорскую пару против источника
|
||||
(`CalculateNonStreamingTimeout`: час на 128 000 токенов), с контролем «грант ровно в бюджет обязан
|
||||
вывестись ровно в окно» — иначе константы были бы украшением рядом с деривацией, а не её источником.
|
||||
⚠ Это НЕ нарушает `TestTheDeadlineTestQuotesNoDeadline`: тот банит ПРОИЗВОДНЫЕ секунды (его собственный
|
||||
банлист печатается прогоном: `[238 239 240 899 900 901]`), а вендорская пара — исходные числа, то есть
|
||||
единственное место, где арифметика касается внешнего мира. Мутации `CUTDEADLINE-the-vendor-budget-moves`
|
||||
и `-window-moves` — RED. **И этикетка прибора исправлена тем же движением:** комментарий `vendorSeconds`
|
||||
утверждал о себе «a re-derivation from the source numbers and not a copy of the code under test» —
|
||||
теперь он говорит, что пинует РАСПОЛОЖЕНИЕ и не пинует числа, и называет, кто пинует их.
|
||||
|
||||
#### 10.09 — ЧИСЛА ПОСЛЕ ВСЕХ ПОЧИНОК. Работа завершена, править не планирую
|
||||
|
||||
Сняты ПОСЛЕ последней правки, по одному прогону за раз (параллельный запуск ронял машину по памяти):
|
||||
|
||||
```
|
||||
make battery → MAKE-EXIT=0 · 19 ok · 0 FAIL · 4 «no test files» · 4 скипа, названы:
|
||||
TestMinerFullBookParity · TestCorpusBankKeyConflicts
|
||||
TestHelperEventsRun · TestHelperKillLoop (тот же список, что в baseline смены)
|
||||
make mutations → MAKE-EXIT=0 · 149 посадок · 149 RED · 0 выживших
|
||||
0 NOTHING · 0 ROTTED · 0 INCONCLUSIVE · якорей протухших 0 из 360
|
||||
counts.py → литералы сходятся (8 проверок)
|
||||
```
|
||||
|
||||
Каталог за пак: **276 → 360**, батарейное подмножество **65 → 149**. Дерево: 28 путей, вне зоны 0.
|
||||
|
||||
**Самопроверка отчёта, механическая.** Из полей «предъявлено» наряда вынуто 25 имён тестов и 24
|
||||
идентификатора мутаций: **несуществующих ноль** (контроль: тестов в дереве 1369, записей в каталоге 360).
|
||||
Все 24 — в батарейном подмножестве, с `run`-фильтром, и **все 24 покраснели** в финальном прогоне.
|
||||
|
||||
⚠ **Тот же прибор нашёл в ЖУРНАЛЕ пять имён без предмета** — прежний пин write-бонда и четыре записи
|
||||
каталога, снятые по ходу смены. Все пять были верны на момент записи, но читатель, грепнувший имя,
|
||||
не нашёл бы его и решил, что отчёт лжёт. ⇒ каждое помечено как хроника прямо на месте, с указанием,
|
||||
когда и почему предмет исчез. Непомеченных: 0.
|
||||
|
||||
**Инцидент со своим деревом — назван и пере-проверен ДВУМЯ приборами.** `cd` в несозданный каталог
|
||||
провалился, `set -e` не удержал, и мутация ушла в настоящее дерево; поймана следующей командой и
|
||||
восстановлена. Пере-проверка не грепом по порче (у мутаций-усечений порча есть ПРЕФИКС цели и
|
||||
присутствует всегда), а поиском пропавшей ЦЕЛИ: **370 правок каталога в 62 файлах, целей не на месте —
|
||||
ноль**. Оркестратор снял то же число своим прибором независимо. Норма записана в `CLAUDE.md`
|
||||
(`6ee6c61`): копия под мутацию защищается ПОСТРОЕНИЕМ — `test -f go.mod` плюс сверка `pwd` перед любой
|
||||
правкой, копия вне общего скретчпада.
|
||||
|
||||
#### Вопрос владельцу и оркестратору — один, и он ЖДЁТ вердикта о мягком останове
|
||||
|
||||
Чинить ли 31 позицию наряда моей рукой в этом контексте. Довод ПРОТИВ я предъявила сама и снимать его не
|
||||
буду: семь кругов, частота находок не падает (7 → 3 → 2 → 2 → 3 → 29), шесть из семи находили дефекты в
|
||||
|
|
@ -685,6 +1011,13 @@ retry-loop 6→3 · bank-and-counters 6→6 · transport-and-config 9→7
|
|||
заменяет пак, а канон прямо разрешает дофикс по СВОЕМУ паку отработавшей сессии («отработавшей пишут
|
||||
ТОЛЬКО по её же паку — вопросы, ревью, диспозиции, дофиксы»).
|
||||
|
||||
⛔ **Но отвечать на него сейчас НЕЛЬЗЯ, и причина не в моём контексте.** Владелец предложил конструкцию,
|
||||
которой в паке не было: **два останова** — жёсткий гасит всё немедленно, мягкий не рвёт летящие вызовы
|
||||
(его довод: обрывать соединение и посылать заново значит терять до трети стоимости). Если пользовательская
|
||||
остановка перестанет рвать вызовы, класс «деньги без ответа» не возникает вовсе — и чинить придётся не 31
|
||||
позицию, а другое их число. ⇒ **вопрос ждёт вердикта консилиума** (`D39.231` п.4), правки не начаты,
|
||||
дерево не трогается.
|
||||
|
||||
⚠ И отдельно — **против отката денежной части**, если он будет рассматриваться. Откат разведения
|
||||
предикатов вернул бы состояние, где отклонённые провайдером запросы становятся ПЛАТНЫМИ (замер: 22 из 25,
|
||||
$0.80 на боевом пути) — списание с читателя за вызов, которого никто не выполнял. Это направление
|
||||
|
|
|
|||
|
|
@ -8,6 +8,32 @@
|
|||
> **Довод против раздвоения:** вести поправки отдельно от наряда значит завести второй носитель одного
|
||||
> знания — ровно то, из-за чего сегодня терялись решения (`D39.216` п.3б).
|
||||
|
||||
> ⛔ **АМЕНДМЕНТ 10.09 (шаг 0 наряда работ). Наряд писан ДО вердикта консилиума; ниже он приведён в
|
||||
> соответствие, и КАЖДАЯ правка помечена как правка.** Внесено движковой сессией по зонному исключению
|
||||
> выше. Что изменилось:
|
||||
> **(1)** вписан ЗНАМЕНАТЕЛЬ — новый раздел «Знаменатель класса» перед «ЧТО ЧИНЮ»;
|
||||
> **(2)** позиции получили поля `исход` и `предъявлено` — по ним сверяется завершённость, машинно;
|
||||
> **(3)** P0 размечены по СЕМЬЯМ (читатели контракта · выходы цепочки `retryLoop` · локальные) — семья
|
||||
> решает порядок работы, потому что чинить членов семьи поодиночке уже пробовали семь раз;
|
||||
> **(4)** отмечено, что КНОПКА — не главный источник отмен, и это меняет вес нескольких позиций;
|
||||
> **(5)** заведена ОДНА новая позиция, которой в наряде не было: `круг8#1`.
|
||||
>
|
||||
> ⛔ **И вторая поправка, более важная: наряд НЕС форму правильной починки, а я её не прочла.** Восьмой
|
||||
> круг нашёл, что все три зонда спрашивают ФИКСИРОВАННЫЙ индекс попытки, тогда как воронка шагает ожоги и
|
||||
> покупает на следующем. Это ровно позиция `bank-and-counters#2`, P1, где дословно записано: «Зонд зашит
|
||||
> на попытку 0, а обход ожога идёт по ВОЗРАСТАЮЩИМ индексам… Зонд должен спрашивать ту же ось, что и
|
||||
> обход». ⇒ **наряд ПРЕДСКАЗАЛ регрессию, которую внесла моя починка**, а я чинила по заказу письмом, не
|
||||
> открыв наряд. Если бы работа шла по наряду в его порядке, верная форма была бы названа ДО написания
|
||||
> неверной. Это второй за день довод в пользу наряда как носителя — и первый, где цена невнимания к нему
|
||||
> измерена: одна регрессия, пойманная только направленным вторым читателем.
|
||||
>
|
||||
> ⚠ **Поправка к тому, как этот наряд был передан.** Заказ на два предгейта пришёл со словами «этого не
|
||||
> видела ни одна линза наряда». Это НЕВЕРНО, и оркестратор поправку принял: наряд несёт находку ТРЕМЯ
|
||||
> позициями — `burn-walk#2`, `burn-walk#3` и дубль `bank-and-counters#3`, где дословно стоит «два ДРУГИХ
|
||||
> зонда «чекпойнт есть ⇒ уже оплачено» про ожог не знают». ⇒ **консилиум пере-открыл ЧТЕНИЕМ то, что
|
||||
> линза нашла ИСПОЛНЕНИЕМ.** Это улика в пользу НАРЯДА, а не в пользу консилиума, и она же объясняет,
|
||||
> почему в заказе была переоценена новизна находки.
|
||||
|
||||
> ⚠ **РЕВЬЮ-ШАПКА ОРКЕСТРАТОРА (перенос в репозиторий 08.09).** Это наряд СЕДЬМОГО адверсариального круга
|
||||
> движковой сессии `textmachine-79` по её же дофиксу пака «ВЫЗОВ, КОТОРЫЙ ОБОРВАЛИ МЫ». Перенесён
|
||||
> ДОСЛОВНО и без правок: он жил в `/tmp` и умер бы вместе с сессией, а по канону доказательная база
|
||||
|
|
@ -66,11 +92,65 @@
|
|||
«дубль» и чинятся вместе. Дубли НЕ выброшены: каждая оставлена со своим ключом, чтобы при проверке
|
||||
было видно, что ни одна не потеряна.
|
||||
|
||||
## Знаменатель класса — ВПИСАН АМЕНДМЕНТОМ 10.09, в наряде его не было
|
||||
|
||||
Наряд перечислял дефекты, но не отвечал на вопрос «сколько их вообще может быть». Вердикт консилиума
|
||||
ответил «читателей контракта три»; пере-проверка исполнением показала, что это верно **про один глагол**
|
||||
и ложно про предмет. Формулировка, утверждённая оркестратором дословно:
|
||||
|
||||
> **денежных читателей контракта ТРИ, закрыты 3 из 3; читателей таблицы `checkpoints` — двенадцать
|
||||
> функций, ожог лжёт четырём площадкам в двух классах.**
|
||||
|
||||
⛔ **Голое «три» публиковать нельзя:** следующая смена прочтёт его как «мест всего три» и не пере-проверит.
|
||||
|
||||
**Как считалось.** Не по именам функций, а по обращению к таблице: `checkpoints` трогают **12** функций
|
||||
`store` (прибор прочёл 12 нетестовых файлов `internal/store`). Счёт по именам дал бы 5 и пропустил
|
||||
`EscalationHops` · `EscalationSpentUSD` · `RoleResponsesForBook` · `SpendByModel` · `ResetChunkStages` —
|
||||
у них предмета в названии нет.
|
||||
|
||||
| читатель | площадок | врёт ли ему ожог |
|
||||
|---|---|---|
|
||||
| `GetCheckpoint` | 10 | **3 денежных предгейта — ДА (закрыты 3 из 3)** · 2 воронка (определение) · 5 под `FinalHash` — недостижимы |
|
||||
| `HasCheckpointForStage` | 1 | **ДА, но не деньгами, а ПРИЧИНОЙ в строке оператора** → позиция `круг8#2` |
|
||||
| `RoleResponsesForBook` | 1 | НЕТ: зовётся с непустым `mustContain`, запрос фильтрует `instr(response_text, ?) > 0`, пустой текст ожога не совпадёт никогда |
|
||||
| `EscalationHops` | 1 | НЕТ: его док — «counts the escalation CALLS a book has PAID for», а ожог именно оплаченный вызов |
|
||||
| `CheckpointUsageForBook` | 3 | НЕТ: ожог несёт настоящие деньги при нулевых токенах ⇒ попадает в оценочную долю, как задумано |
|
||||
| `RepairStats` | 1 | НЕТ: ожог — настоящий вызов; `declined` матчит по сентинелу, пустой текст ≠ сентинел |
|
||||
| `EscalationSpentUSD` · `RoleSpentUSD` · `SpendByModel` | 1 · 2 · 1 | НЕТ: суммируют деньги, а деньги ожога настоящие |
|
||||
|
||||
⚠ **Пять площадок под `FinalHash` недостижимы ТРЕМЯ независимыми доводами, а не одним** (общий довод на
|
||||
пять случаев был бы слабее любого из них): все пять под охраной `Disposition == ok`; писателей `FinalHash`
|
||||
ровно ДВА, и `cutcall.go:171` пишет пустую строку явно, а `stagerun.go:321` берёт значение, которое
|
||||
инициализировано `""` и присваивается только под `DispOK`; и даже на `DispOK` берётся хеш ОТВЕТИВШЕЙ
|
||||
попытки, потому что прогулка по ожогу уже увела индекс. Исполнением это держит существующая фикстура
|
||||
`TestABurnedCheckpointIsNeverReadAsAnAnswer` (`cutcall_test.go:802`) — второй носитель не заводился.
|
||||
|
||||
⚠ **Расхождение счёта, названное, а не «исправленное»:** у `RoleSpentUSD` вызывающих в движке **2**
|
||||
(`terminologist.go:916`, `rebill.go:557`), а площадок вообще **3** — третья это `store/ledger.go:102`, тело
|
||||
`RepairSpentUSD`, то есть глагол, выраженный через глагол. Оба числа верны о разных вопросах.
|
||||
|
||||
⛔ **И ГЛАВНОЕ, что показал восьмой круг: знаменатель закрывает одну ОСЬ, а не работу.** Читателей правда
|
||||
три — а дефект сидел не в их ЧИСЛЕ, а в ФОРМЕ ВОПРОСА, одинаковой у всех трёх. Никакой счёт читателей
|
||||
его не ловит; поймал направленный второй читатель. ⇒ находка вида «дефект одинаков у всех членов семьи»
|
||||
означает, что неверен ВОПРОС, а не позиции, и чинится определением, а не пунктами наряда.
|
||||
|
||||
## Источники отмен — ВПИСАНО АМЕНДМЕНТОМ 10.09
|
||||
|
||||
⚠ **Кнопка — не главный источник `cancelled`, и это меняет вес позиций.** Волна гасит собственный контекст
|
||||
изнутри на первой же ошибке или панике (`waverun.go:404`, `:414` — единственные `cancel()` вне `defer` при
|
||||
140 нетестовых файлах), то есть ОДНА инфра-ошибка в одном воркере метит `cancelled` все летящие вызовы
|
||||
соседей. Позиции, чья мотивация в наряде звучала как «это редкий случай нажатия кнопки», надо читать как
|
||||
класс, который приходит пачками. Мягкий останов (обсуждается владельцем) делает вторую половину РЕДКОЙ,
|
||||
а не ненужной.
|
||||
|
||||
## ЧТО ЧИНЮ — по приоритету, в порядке работы
|
||||
|
||||
### P0 · `bank-and-counters#1` — Ожог в банковой партии режет УЖЕ ОПЛАЧЕННЫЕ партии: проход, стоящий $0.000000, дропается целиком
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Приём партий переведён с ПРЕФИКСА на попартийное решение (`terminologist.go:941-970`): отказ в одной партии больше не уносит те, что позади и стоят $0. Поле `run.attempted` (счётчик «сколько с начала») заменено на `run.ran []bool` — счётчик не может описать несплошное множество, и оба его читателя (`:487`, `:667`) переведены с `break` на `continue`. Попутно снято мёртвое `paidBatch` (писалось, никем не читалось). Пин `TestAnUnaffordableBatchDoesNotTakeThePaidBatchesBehindIt` с контролем «провайдера не спросили ни разу»; мутация `CUTBANK-admission-is-a-prefix-again` RED текстом «an ALREADY-PAID batch sitting behind a refused one was dropped»
|
||||
- **семья:** читатели контракта ⟨вписано амендментом 10.09⟩
|
||||
- **где:** `internal/pipeline/terminologist.go:947-961`
|
||||
- **как:** Префиксный `break` роняет УЖЕ ОПЛАЧЕННЫЕ партии, когда одна из ранних сожжена: проход, стоящий $0, дропается целиком. Пропускать сожжённую, а не обрывать префикс.
|
||||
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
|
||||
|
|
@ -82,6 +162,9 @@
|
|||
### P0 · `bank-and-counters#3` — Два ДРУГИХ зонда «чекпойнт есть ⇒ уже оплачено» про ожог не знают: перепокупка уходит мимо суб-бюджета эскалации и ремонта
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **семья:** читатели контракта ⟨вписано амендментом 10.09⟩
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** дубль `burn-walk#2` и `#3`, закрыт той же правкой — одним определением на три площадки, а не двумя строками
|
||||
- **где:** `escalation.go:146, repair.go:368`
|
||||
- **как:** дубль burn-walk#2 и #3, чинится теми же двумя правками
|
||||
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
|
||||
|
|
@ -93,6 +176,9 @@
|
|||
### P0 · `bank-and-counters#4` — Доставленный обрыв ТЕРЯЕТСЯ на обоих выходах retryLoop по отмене: до вызывающего не доходит ни Deliveries, ни сам обрыв
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** дубль `money-predicate#1`: оба выхода по отмене теперь идут через тот же `chainError`. Предъявлено тем же пином и мутацией `CUTCHAIN-the-stop-exit-drops-what-is-owed` ⛔ **ПОПРАВКА 10.09, вечер: прежнее предъявление было ЛОЖНЫМ, снимаю сама.** Охотник оркестратора посадил снятие `cancelledDuring` на ПЕРВОМ выходе по отмене (`httpllm.go:199`) — батарея осталась зелёной, воспроизвела сама. Механизм: мой пин гонит петлю ЧЕРЕЗ ПРОВОД, и там стоп приходит по вызову в полёте, поэтому ошибка самой попытки — уже родительски-отменённый обрыв, несущий `context.Canceled` в поле `Parent`. Утверждение «прогон обязан читаться как отменённый» удовлетворялось ЧУЖОЙ уликой, а не тем выходом, который позиция объявляла закрытым (замер: `isCanceled=true cause=connection_lost` в 6 прогонах из 6 на мутанте). ⇒ заведён `TestTheStopExitStillReadsAsCancelledWhenTheAttemptDidNot`: гонит `retryLoop` НАПРЯМУЮ, попытка падает обычной 503 и стоп приходит на её возврате — опереться не на что, кроме самого выхода. Провод такой порядок создать не может: окно между возвратом попытки и чтением контекста в несколько инструкций, и фикстура, гоняющаяся за ним, мерила бы планировщик. Мутация `CUTCHAIN-the-stop-exit-drops-the-cancellation` RED. Заодно закрыта названная охотником связка «новый тип ↔ код выхода»: `AttemptCutError` давал **0 хитов** в тестах `cmd/tmctl` при 26 вызовах отображения — заведён `TestTheCutErrorTypeKeepsItsExitCode` (пять форм плюс контроль «потолок всё ещё старше обрыва»), мутация `CUTEXIT-a-stop-stops-mapping-to-five` RED.
|
||||
- **семья:** выходы цепочки retryLoop ⟨вписано амендментом 10.09⟩
|
||||
- **где:** `internal/llm/httpllm.go:211 и :234`
|
||||
- **как:** дубль money-predicate#1: оба выхода по отмене зовут cancelledDuring БЕЗ withEarlierCut, поэтому доставленный обрыв до вызывающего не доходит.
|
||||
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
|
||||
|
|
@ -104,6 +190,9 @@
|
|||
### P0 · `burn-walk#1` — Деньги сожжённых ключей ТЕРЯЮТСЯ на свежем вызове: burnedCost перезаписывается, а не прибавляется
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Семья закрыта ОДНИМ инвариантом, предъявленным на всех трёх: строка чанка обязана сходиться с суммой чекпойнтов СВОЕЙ позиции на ЛЮБОМ выходе. Носитель — `assertRowsMatchTheLedger` с контролем «прибору дали что сравнивать» (иначе «расхождений нет» на пустом леджере читается как успех). Обе ветки (`stagerun.go:717` billed-decode и `:789` свежий вызов) прибавляют `burnedCost`, а не затирают его. Пин `TestABurnedKeysMoneyReachesTheRow` (два обрыва подряд — один не даёт ожога, его ретраит транспорт; ожог рождается на КАПЕ второго — затем резюм). Мутация `CUTROW-the-burn-money-is-overwritten` RED: «says it cost 0.002000 while its own checkpoints add up to 0.003056» ⛔ **ПОПРАВКА 10.09, вечер: прежнее предъявление было ЛОЖНЫМ, снимаю сама.** Охотник оркестратора посадил снятие `cancelledDuring` на ПЕРВОМ выходе по отмене (`httpllm.go:199`) — батарея осталась зелёной, воспроизвела сама. Механизм: мой пин гонит петлю ЧЕРЕЗ ПРОВОД, и там стоп приходит по вызову в полёте, поэтому ошибка самой попытки — уже родительски-отменённый обрыв, несущий `context.Canceled` в поле `Parent`. Утверждение «прогон обязан читаться как отменённый» удовлетворялось ЧУЖОЙ уликой, а не тем выходом, который позиция объявляла закрытым (замер: `isCanceled=true cause=connection_lost` в 6 прогонах из 6 на мутанте). ⇒ заведён `TestTheStopExitStillReadsAsCancelledWhenTheAttemptDidNot`: гонит `retryLoop` НАПРЯМУЮ, попытка падает обычной 503 и стоп приходит на её возврате — опереться не на что, кроме самого выхода. Провод такой порядок создать не может: окно между возвратом попытки и чтением контекста в несколько инструкций, и фикстура, гоняющаяся за ним, мерила бы планировщик. Мутация `CUTCHAIN-the-stop-exit-drops-the-cancellation` RED. Заодно закрыта названная охотником связка «новый тип ↔ код выхода»: `AttemptCutError` давал **0 хитов** в тестах `cmd/tmctl` при 26 вызовах отображения — заведён `TestTheCutErrorTypeKeepsItsExitCode` (пять форм плюс контроль «потолок всё ещё старше обрыва»), мутация `CUTEXIT-a-stop-stops-mapping-to-five` RED.
|
||||
- **семья:** локальные ⟨вписано амендментом 10.09⟩
|
||||
- **где:** `internal/pipeline/stagerun.go:784 и :713`
|
||||
- **как:** В обеих ветках писать `burnedCost + cost` / `burnedCost + estimate` вместо голого присваивания. Пин: прогон с ДВУМЯ сожжёнными ключами подряд, сверка chunk_status.cost_usd с SUM(checkpoints.cost_usd) SQL-запросом.
|
||||
- **тяжесть после опровержения:** correctness · **источник:** линза+опровергатель
|
||||
|
|
@ -115,6 +204,9 @@
|
|||
### P0 · `burn-walk#2` — Сожжённый ключ РЕМОНТА читается как «уже оплачено» — суб-бюджет repair обходится целиком
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **семья:** читатели контракта ⟨вписано амендментом 10.09⟩
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** одно определение `paidAfterBurns` (`cutcall.go:196`) + `repair.go:373`; пины `TestABurnedRepairKeyDoesNotBuyARepairOutsideTheSubBudget` и `TestAPaidRepairBehindABurnedKeyStillReplaysFree`; мутации `CUTBURN-the-paid-contract-ignores-the-burn`, `…-stops-at-the-first-key`, `…-the-repair-pregate-asks-a-fixed-index` — все RED по тексту. Воспроизведено красным ДО починки: «want 2 repair calls, got 3 — one of them was bought outside gates.repair.budget_usd»
|
||||
- **где:** `internal/pipeline/repair.go:368`
|
||||
- **как:** Зонд «чекпойнт есть ⇒ оплачено» обязан видеть ожог: `cp != nil && !burnedByCut(cp)`. Ровно та правка, что уже сделана для банка (terminologist.go). Пин — по образцу TestTheBankPaidProbeSeesThroughABurnedCheckpoint, на зонде, а не на предикате.
|
||||
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
|
||||
|
|
@ -126,6 +218,9 @@
|
|||
### P0 · `burn-walk#3` — Сожжённый ключ ХОПА читается как «уже оплачено» — escalation.budget_usd не спрашивается (и escMu не берётся)
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **семья:** читатели контракта ⟨вписано амендментом 10.09⟩
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** `escalation.go:141` через то же определение; пины `TestABurnedHopKeyDoesNotBuyAHopOutsideTheEscalationBudget` и `TestAPaidHopBehindABurnedKeyStillReplaysFree`; мутация `CUTBURN-the-escalation-pregate-asks-a-fixed-index` RED. Воспроизведено красным ДО починки: «want 2 hop calls, got 3»
|
||||
- **где:** `internal/pipeline/escalation.go:146`
|
||||
- **как:** То же для `mayHop := fbExists != nil`. Дополнительно проверить, что escMu берётся, раз хоп реально пойдёт на провод.
|
||||
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
|
||||
|
|
@ -137,6 +232,9 @@
|
|||
### P0 · `cancelled-mark#1` — Метка над эскалационным хопом не несёт денег хопа: ledger 0.001176, строка 0.000120
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Семья закрыта ОДНИМ инвариантом, предъявленным на всех трёх: строка чанка обязана сходиться с суммой чекпойнтов СВОЕЙ позиции на ЛЮБОМ выходе. Носитель — `assertRowsMatchTheLedger` с контролем «прибору дали что сравнивать» (иначе «расхождений нет» на пустом леджере читается как успех). Деньги хопа едут в `out.fb` ВСЕГДА (`escalation.go`), а `runStage` прибавляет их ДО проверки ошибки. Пин `TestAStoppedRunLeavesEveryRowMatchingItsOwnLedger`; мутация `CUTROW-the-hop-money-waits-for-the-verdict` RED текстом «0.000120 против 0.001176» — ровно те числа, что замерила линза. ⚠ Первая редакция пина была ФЛЕЙКОВОЙ: 2 красных из 8 на мутанте, потому что отмена обгоняла разбор заголовков и обрыв выходил на $0, а строка сходилась «ноль к нулю». Перестроена так, что деньги хопа НЕИЗБЕЖНЫ (ожог на ключе хопа в прогоне 1): 8/8 зелёных на дереве, 8/8 красных на мутанте
|
||||
- **семья:** локальные ⟨вписано амендментом 10.09⟩
|
||||
- **где:** `internal/pipeline/stagerun.go:233-240 + escalation.go:173`
|
||||
- **как:** Деньги хопа прибавлять к cumCost ДО возврата по ошибке (сейчас только внутри `if esc.attempted`, то есть после `if err != nil { return }`). Пин: отмена над хопом, сверка chunk_status.cost_usd с леджером.
|
||||
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
|
||||
|
|
@ -148,6 +246,9 @@
|
|||
### P0 · `cancelled-mark#2` — Тот же провал на ремонте: ledger 0.001303, сумма всех строк 0.000240
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Семья закрыта ОДНИМ инвариантом, предъявленным на всех трёх: строка чанка обязана сходиться с суммой чекпойнтов СВОЕЙ позиции на ЛЮБОМ выходе. Носитель — `assertRowsMatchTheLedger` с контролем «прибору дали что сравнивать» (иначе «расхождений нет» на пустом леджере читается как успех). То же для ремонта: `res.CostUSD`/`res.CumUSD` заполняются ДО суждения об исходе (`repair.go`), `runStage` прибавляет их до `if rerr != nil`. Пин `TestAStoppedRunCarriesTheRepairsMoneyToTheRow`, построенный тем же приёмом неизбежных денег; мутация `CUTROW-the-repair-money-waits-for-the-verdict` RED: «0.002000 против 0.003063». ⚠ Мутация СНАЧАЛА ВЫЖИЛА — носителя не было, и это поймал инструмент, а не я
|
||||
- **семья:** локальные ⟨вписано амендментом 10.09⟩
|
||||
- **где:** `internal/pipeline/stagerun.go:281-287 + repair.go:302-316`
|
||||
- **как:** То же для ремонта: замер линзы — леджер 0.001303, сумма строк 0.000240.
|
||||
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
|
||||
|
|
@ -159,6 +260,9 @@
|
|||
### P0 · `money-predicate#1` — Оплаченный обрыв теряется целиком, если между ним и остановкой был ЛЮБОЙ не-cut ретраибл (503/429)
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Семья закрыта ОДНИМ контрактом, а не четырьмя правками: `moreOwed` копит то, что цепочка должна вынести, `chainError` цепляет это к любому из ЧЕТЫРЁХ выходов (`httpllm.go`), и решение принимается по ДЕНЬГАМ, а не по типу ошибки. Пин `TestAPaidCutSurvivesAPlainRetryableLaterInTheChain` (обрыв → 503 → исчерпание) и `TestTheCancellationExitCarriesWhatTheChainOwes` (второй выход по отмене, синхронизирован на СТОП, не на часы). Мутации `CUTCHAIN-a-plain-retryable-erases-the-paid-cut` и `CUTCHAIN-the-stop-exit-drops-what-is-owed` — RED по тексту
|
||||
- **семья:** выходы цепочки retryLoop ⟨вписано амендментом 10.09⟩
|
||||
- **где:** `internal/llm/httpllm.go:211 и :234`
|
||||
- **как:** Оплаченный обрыв теряется, если между ним и концом цепочки был ЛЮБОЙ не-cut ретраибл (503/429). Причина та же, что у #2: firstCut не доносится. Чинить вместе с money-predicate#2 и bank#4 — это одна семья из трёх выходов.
|
||||
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
|
||||
|
|
@ -170,6 +274,9 @@
|
|||
### P0 · `money-predicate#2` — Оплаченный обрыв маскируется НЕоплачиваемым обрывом той же цепочки: книжится $0
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Семья закрыта ОДНИМ контрактом, а не четырьмя правками: `moreOwed` копит то, что цепочка должна вынести, `chainError` цепляет это к любому из ЧЕТЫРЁХ выходов (`httpllm.go`), и решение принимается по ДЕНЬГАМ, а не по типу ошибки. Правило выхода сменено с «финальная ошибка несёт ЛЮБОЙ обрыв» на «несёт обрыв с денежным требованием не слабее». Пин `TestAFreeCutLaterDoesNotMaskThePaidCutEarlier`; мутация `CUTCHAIN-the-exit-ranks-cuts-by-type-not-money` RED. ⚠ Попутно найдена и снята МОЯ недостижимая ветка: предпочтение «платный над бесплатным» в накопителе сработать не может — ретраибл только `CutByConnection`, а кап делает ВТОРОЙ доставленный обрыв терминальным, значит обрывов в цепочке максимум два и второй всегда её заканчивает. Ранжирование живёт на выходе, где обе ошибки в руках
|
||||
- **семья:** выходы цепочки retryLoop ⟨вписано амендментом 10.09⟩
|
||||
- **где:** `internal/llm/httpllm.go:249-251 (withEarlierCut)`
|
||||
- **как:** ОБЪЕДИНЕНО с retry-loop#1. `withEarlierCut` отдаёт финальную ошибку, если она несёт ЛЮБОЙ обрыв. После разведения предикатов это неверно: поздний Billable=false затирает ранний Billable=true. Правило должно быть «предпочесть ОПЛАЧИВАЕМЫЙ обрыв», а не «любой».
|
||||
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
|
||||
|
|
@ -181,6 +288,9 @@
|
|||
### P0 · `retry-loop#1` — withEarlierCut выбрасывает ОПЛАЧЕННЫЙ обрыв первой попытки, если цепочку заканчивает ДРУГОЙ обрыв — движок книжит $0
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** дубль `money-predicate#2`, закрыт тем же контрактом. Дополнительный пин `TestAPaidCutAfterAFreeOneIsTheOneTheCallerSettlesFrom` держит обратный порядок (бесплатный, затем платный) с премиссой на РОВНО два вызова — если политика ретраев когда-нибудь пустит третий, премисса скажет об этом вслух, а не даст фикстуре молча мерить другую цепочку. ⚠ Три ЧУЖИХ якоря каталога протухли от переписи петли и пере-нацелены поштучно, каждый пере-проверен посадкой
|
||||
- **семья:** выходы цепочки retryLoop ⟨вписано амендментом 10.09⟩
|
||||
- **где:** `internal/llm/httpllm.go:248-251`
|
||||
- **как:** дубль money-predicate#2
|
||||
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
|
||||
|
|
@ -192,6 +302,9 @@
|
|||
### P0 · `критик#2` — `cancelled` читается прогнозом как ПОЛНОСТЬЮ отработанная позиция: одна остановка роняет projected_book_usd на 9.9% — носитель resolvedForResume правлен в двух местах из четырёх
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Предикат `resolvedForResume` подставлен в ЧЕТВЁРТЫЙ носитель — `resolveChunkState` (`status.go:576`): строка `cancelled` больше не решает судьбу единицы, и `projectBookUSD` не берёт её в знаменатель экстраполяции. Довод наряда точен и решил форму починки: ДО пака остановка не оставляла строки вовсе, знаменатель был честен, и ухудшила его именно новая метка — значит чинить надо восстановлением прежней правды, а не новым правилом. Пин `TestAStoppedPositionIsNotADecidedUnit` держит ОБЕ стороны (остановленная позиция не решает; обычный контентный флаг по-прежнему решает — иначе предикат, отвечающий «не решено» на всё, прошёл бы половину теста и тихо выключил флагирование). Мутация `CUTSTATE-a-stopped-position-decides-the-unit` RED по тексту. ⚠ ПИНГ оркестратору: правка соприкасается со строкой бэклога **374** (вердикт главы считается по СЧЁТУ флагов) — остановленная позиция теперь не попадает в этот счёт на уровне единицы, что 374 частично снимает; решение о вердикте ГЛАВЫ остаётся его
|
||||
- **семья:** локальные ⟨вписано амендментом 10.09⟩
|
||||
- **где:** `internal/pipeline/rebill.go:279 + status.go:564-573`
|
||||
- **как:** resolvedForResume подставлен в ДВА носителя из ЧЕТЫРЁХ: прогноз и состояние чанка читают `cancelled` как полностью отработанную позицию ⇒ одна остановка роняет projected_book_usd на 9.9 %. Это число читает платформа.
|
||||
- **тяжесть после опровержения:** money · **источник:** критик полноты
|
||||
|
|
@ -201,6 +314,8 @@
|
|||
### P1 · `bank-and-counters#2` — Зонд смотрит только на попытку 0: партия, уже ОТВЕЧЕННАЯ на попытке 1, требует полный `want` из суб-бюджета, который никогда не потратит
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** `terminologist.go:792` переведён на `paidAfterBurns`; пин `TestTheBankProbeFindsThePaidBatchBehindABurnedKey`, мутация `CUTBURN-the-bank-pregate-asks-a-fixed-index` RED текстом «the batch WAS bought and answered at the index the funnel walked to». ⚠ Эта позиция и есть та, что ПРЕДСКАЗАЛА регрессию двух других зондов — см. амендмент
|
||||
- **где:** `internal/pipeline/terminologist.go:792`
|
||||
- **как:** Зонд зашит на попытку 0, а обход ожога идёт по ВОЗРАСТАЮЩИМ индексам: партия, отвеченная на попытке 1, требует полный want из суб-бюджета, который не потратит. Зонд должен спрашивать ту же ось, что и обход.
|
||||
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
|
||||
|
|
@ -212,6 +327,8 @@
|
|||
### P1 · `bank-and-counters#5` — `Deliveries` считает ОБРЫВЫ, а не доставки: и доккомментарий, и строка леджера утверждают число, которое замеряется неверным
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Выбран честный счёт, а не переименование: строку читает ОПЕРАТОР, и она единственное место, где виден разрыв между «сколько сгенерировали» и «сколько забукали». Счётчик разведён на два — кап ретраев остаётся на обрывах (`deliveredCutSeen`), а число для строки считается по ФАКТУ доставки (`askedToGenerate` + предикат `deliveredAttempt`): статус любого рода есть доставка по определению (пир прочитал запрос, чтобы ответить), оплаченный нечитаемый 2xx — тоже; не доставка — всё, что упало до ухода байтов. Доккомментарий поля приведён в соответствие. Пин `TestTheDeliveryCountCountsDeliveriesNotCuts` (503 → обрыв → его единственный ретрай = ТРИ доставки), число вызовов закреплено премиссой, чтобы смена политики ретраев не дала фикстуре молча мерить другую цепочку. Мутация `CUTCOUNT-deliveries-counts-cuts-again` RED. ⚠ Правка сломала ЧУЖОЙ якорь `CUTCALL-the-delivery-count-is-not-carried` — пере-нацелен и пере-проверен посадкой
|
||||
- **где:** `internal/llm/attemptcut.go:104-109 + httpllm.go:459-470 + pipeline/cutcall.go:115-122`
|
||||
- **как:** Deliveries инкрементируется только под `errors.As(err,&cut)`, то есть считает ОБРЫВЫ, а не доставки: доккомментарий и строка леджера называют неверно замеренное число. Либо считать доставки честно, либо переименовать поле и текст.
|
||||
- **тяжесть после опровержения:** correctness · **источник:** линза+опровергатель
|
||||
|
|
@ -223,6 +340,8 @@
|
|||
### P1 · `burn-walk#4` — Строка остановленной позиции пишет attempts=0 при реально уплаченных деньгах
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** `attemptsMade` пишется ДО проверки ошибки (`stagerun.go`), по той же причине, что и деньги рядом: счёт — факт о случившемся, а не о том, удалось ли. Инвариант строки расширен: «стоила денег ⇒ обязана назвать хотя бы одну попытку». Пин `TestAStopOnAStagesFirstCallStillReportsAnAttempt` — остановка на ПЕРВОМ вызове стадии, деньги неизбежны через ожог; мутация `CUTROW-the-attempt-count-waits-for-the-verdict` RED, и текст падения называет **0.001056** — ровно число линзы. 6/6 зелёных на дереве, 6/6 красных на мутанте. ⚠ Мутация СНАЧАЛА ВЫЖИЛА: во всех прежних сценариях первый вызов стадии успевал отработать, и счёт уже не был нулём — носителя пришлось строить отдельно
|
||||
- **где:** `internal/pipeline/stagerun.go:170-179`
|
||||
- **как:** ОБЪЕДИНЕНО с cancelled-mark#3. `attemptsMade` присваивать ДО проверки ошибки, иначе метка пишет attempts=0 при уплаченных деньгах.
|
||||
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
|
||||
|
|
@ -234,6 +353,8 @@
|
|||
### P1 · `cancelled-mark#3` — Метка врёт числом попыток: attempts=0 при оплаченном cost_usd=0.001056
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** дубль `burn-walk#4`, закрыт той же правкой и тем же пином
|
||||
- **где:** `internal/pipeline/stagerun.go:170-179`
|
||||
- **как:** дубль burn-walk#4, чинится одной правкой
|
||||
- **тяжесть после опровержения:** correctness · **источник:** линза+опровергатель
|
||||
|
|
@ -245,6 +366,8 @@
|
|||
### P1 · `money-predicate#3` — Половина `answered &&` денежного предиката не закреплена НИЧЕМ: мутант выживает во всей батарее
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Половина `answered &&` закреплена фикстурой, где две половины РАСХОДЯТСЯ: пир пишет байты ответа, которые не являются ответом (сломанная статус-строка), так что первый байт трассы есть, а 2xx-объекта нет. Пин `TestResponseBytesWithoutAReplyAreNotAPurchase` с двумя премиссами (это обрыв; `AfterHeaders` истинно — иначе обе половины ложны и тест их не различает); мутация `CUTMONEY-response-bytes-alone-mean-payment` RED текстом «response BYTES are not a reply»
|
||||
- **где:** `internal/llm/attemptcut.go:212`
|
||||
- **как:** Половина `answered &&` денежного предиката не закреплена ничем — мутант переживает всю батарею. Нужна фикстура, где answered=false при firstByte=true (отказ + RST поверх пишущегося тела) И проверка ДЕНЕГ, а не только типа ошибки.
|
||||
- **тяжесть после опровержения:** correctness · **источник:** линза+опровергатель
|
||||
|
|
@ -256,6 +379,8 @@
|
|||
### P1 · `transport-and-config#1` — Пин write-бонда проверяет НЕ тот транспорт, который уходит в бой: удаление тюнинга из боевого клиента переживает всю батарею пакета
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** предмет (пин write-бонда) удалён вместе с бондом. ⚠ КЛАСС при этом воспроизвёлся в моей ЗАМЕНЕ пина и закрыт отдельно: `keepAliveHTTPClient` разделён на себя и `buildCloudClient`, пин читает транспорт боевой конструкции, мутация `CUTKEEPALIVE-the-cloud-client-is-not-tuned-at-all` RED текстом «this test measured nothing»
|
||||
- **где:** `internal/llm/attemptcut_test.go:853 против httpllm.go:124`
|
||||
- **как:** Пин write-бонда конфигурирует СВОЙ транспорт, а не тот, что уходит в бой: удаление тюнинга из боевого клиента переживает всю батарею. Пин обязан читать транспорт, собранный keepAliveHTTPClient.
|
||||
- **тяжесть после опровержения:** correctness · **источник:** линза+опровергатель
|
||||
|
|
@ -267,6 +392,8 @@
|
|||
### P1 · `transport-and-config#2` — Комментарий обещает закрыть awaitFlowControl-парковку — замер показывает, что она открыта: 3-секундный дедлайн держался >70 с на БОЕВОМ клиенте
|
||||
|
||||
- **действие:** ЧИНЮ КОММЕНТАРИЙ, механизм — ПИНГ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** предмет удалён вместе с бондом: комментарий обещал, что бонд закрывает парковку в `awaitFlowControl`, а замер это опровергал. Контроль после удаления: греп `awaitFlowControl|flow control|flow-control` по `backend/` даёт **0 хитов при 360 прочитанных go-файлах** — ложного обещания в дереве не осталось. Класс «дедлайн инертен на h2» остаётся строкой бэклога **373**
|
||||
- **где:** `internal/llm/httpllm.go:97-111`
|
||||
- **как:** Комментарий обещает, что бонд закрывает awaitFlowControl-парковку. ЗАМЕР ОПРОВЕРГАЕТ: 3-секундный дедлайн держался >70 с на боевом клиенте. Комментарий — мой и врёт, его правлю. Оставлять ли сам бонд — решение оркестратора (см. пинги ниже).
|
||||
- **тяжесть после опровержения:** correctness · **источник:** линза+опровергатель
|
||||
|
|
@ -278,6 +405,8 @@
|
|||
### P1 · `transport-and-config#3` — Пол скорости zai — данные без носителя: каталожный гейт zai вообще не смотрит, удаление `tok_s_floor: 35` проходит все 78 кейсов internal/config
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Замер сначала: пол `zai` = 35 против вендорского дефолта 35.56, то есть влияет на 1.6 % — он ЗАМЕРЕН (p10 glm-5 = 37.51 ток/с на 647 строках лога) и осознанно чуть ниже дефолта. Прежний гейт его не видел структурно: он ходит по ЗАГРУЖЕННОЙ структуре, где удалённая строка и никогда не объявленное поле — один и тот же ноль, и пропускает провайдера, чьи модели не объявляют токенного пола. ⇒ заведён `TestEveryDeclaredDeadlineKnobStaysDeclared`: пинует сам ФАКТ объявления по всем четырём провайдерам, с контролем «объявляющих ровно столько, сколько названо в пине». Мутация `CUTCFG-a-measured-speed-floor-is-deleted` RED текстом «deadline knobs moved from {tokSFloor:35 …} to {tokSFloor:0 …}»
|
||||
- **где:** `internal/config/models_catalog_test.go:219-225`
|
||||
- **как:** Гейт смотрит на провайдеров через MinMaxTokens и `if grant == 0 { continue }`, поэтому zai не смотрит вовсе: удаление `tok_s_floor: 35` проходит все 78 кейсов. Расширить гейт на объявленные поля, а не только на выведенные гранты.
|
||||
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
|
||||
|
|
@ -289,6 +418,8 @@
|
|||
### P1 · `transport-and-config#4` — У zai объявлен пол без потолка, и связку никто не валидирует: опечатка в поле даёт 2 ч 32 мин на вызов, а потолок ниже пола обнуляет заявленное «attempt_s — это ПОЛ» до 10 с
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Заведена `validateTimeouts` (`internal/config/models.go`), вызываемая из провайдерского цикла `LoadModels`: диапазоны на все три секундных поля (типо-гвардия 4 часа — на порядок выше ратифицированных ~20 минут, чтобы законная настройка не отвергалась), запрет отрицательного `tok_s_floor` и ГЛАВНОЕ — связка `attempt_max_s >= attempt_s`. Пин `TestACapBelowTheFloorIsRefusedAtLoad` начинается с КОНТРОЛЯ (крышка выше пола и незаданная крышка обязаны грузиться — иначе проверку удовлетворил бы загрузчик, отвергающий всё) и требует, чтобы отказ называл ОБА поля. Мутации `CUTCFG-a-cap-below-the-floor-loads-quietly` и `CUTCFG-the-timeout-check-is-not-wired-in` — обе RED; вторая существует потому, что проверка, которую никто не зовёт, — тот же класс, что я дважды снимала за смену
|
||||
- **где:** `internal/config/models.go:122-146 + internal/llm/attemptcut.go:281-289`
|
||||
- **как:** ОБЪЕДИНЕНО с критик#1. Ни одной проверки диапазона: опечатка в поле даёт 2 ч 32 мин на вызов, а attempt_max_s НИЖЕ attempt_s молча укорачивает каждый вызов — прямо вопреки моему же комментарию «attempt_s is the FLOOR … no provider loses a second it has today». Добавить валидацию в LoadModels и пин.
|
||||
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
|
||||
|
|
@ -300,6 +431,8 @@
|
|||
### P1 · `критик#1` — attempt_max_s ниже attempt_s молча УКОРАЧИВАЕТ дедлайн каждого вызова — вопреки комментарию, который на этом обещании и стоит; ни валидации, ни теста, ни пина
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** дубль `transport-and-config#4`, закрыт той же правкой и тем же пином
|
||||
- **где:** `internal/config/models.go:122-146 + internal/llm/attemptcut.go:281-289`
|
||||
- **как:** ДУБЛЬ transport-and-config#4, чинится той же правкой: валидация связки tok_s_floor/attempt_s/attempt_max_s в LoadModels плюс пин на то, что attempt_max_s ниже attempt_s не проходит загрузку.
|
||||
- **тяжесть после опровержения:** money · **источник:** критик полноты
|
||||
|
|
@ -309,6 +442,8 @@
|
|||
### P1 · `критик#5` — FlagConnectionLost объявлен, отранжирован и НЕДОСТИЖИМ: обоснование в его комментарии не реализовано ни одной строкой кода
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Снята — по прямому указанию наряда «либо сделать достижимым, либо снять константу и ранг». Замер подтвердил недостижимость: `classify` имеет ветви `decodeErrorFinish` и `attemptTimeoutFinish` и НЕ имеет `connectionLostFinish`; настоящую защиту даёт `burnedByCut`, который ловит такую строку ДО любой классификации. Снят и ранг; комментарий на её месте описывает МЕХАНИЗМ («у потери связи нет флага, и это утверждение, а не пропуск»), а не историю правки. Контроль: `FlagConnectionLost` в дереве — 0 хитов кроме объясняющего комментария, при 360 прочитанных go-файлах
|
||||
- **где:** `internal/pipeline/disposition.go:98-104 + status.go:432`
|
||||
- **как:** FlagConnectionLost объявлен, отранжирован и НЕДОСТИЖИМ: обоснование в его комментарии не реализовано ни одной строкой. Либо сделать достижимым, либо снять константу и ранг — но не оставлять словами.
|
||||
- **тяжесть после опровержения:** correctness · **источник:** критик полноты
|
||||
|
|
@ -318,15 +453,32 @@
|
|||
### P1 · `критик#6` — Вся новая таблица тяжести флагов пинуется только на ЧЛЕНСТВО, а не на ЗНАЧЕНИЕ: cancelled можно объявить худшей бедой книги, и батарея пакета зелёная
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Заведён пин на ЗНАЧЕНИЯ, а не на членство: `TestTheSeverityTableMeansWhatItsCommentsSay`. Каждое утверждение — предложение, которое таблица уже пишет о себе сама, превращённое в проверку: `cancelled` мягче всех (его комментарий: «the mildest mark there is»); `decode_error` и `attempt_timeout` делят ранг («they rank together»); `severityUnknown` мягче любого диагноза («must not out-rank a diagnosis the engine actually made»); авто-очищенный кусок мягче бюджетного симптома; выброшенный загрязнённый — строже. Плюс КОНТРОЛЬ в начале: таблица не пуста и не одноцветна, иначе всякое «мягче» держится тривиально. Три мутации RED по тексту
|
||||
- **где:** `internal/pipeline/status.go:430-449, пин flagseverity_test.go`
|
||||
- **как:** Таблица тяжести пинуется на ЧЛЕНСТВО, а не на ЗНАЧЕНИЕ: cancelled можно объявить худшей бедой книги, и батарея зелёная. Нужен пин на ПОРЯДОК (кто кого обязан перевешивать), как у off_target_lang.
|
||||
- **тяжесть после опровержения:** correctness · **источник:** критик полноты
|
||||
- **в чём дефект:** Дофикс переписал порядок тяжести и подробно обосновал каждый ранг (decode_error и attempt_timeout вровень; connection_lost туда же; cancelled — «the mildest mark there is», ниже авто-очистки; unknown сдвинут 8→9). Единственный гейт над этой таблицей — TestEveryFlagReasonIsRanked — проверяет, что каждая ОБЪЯВЛЕННАЯ константа ПРИСУТСТВУЕТ в карте; порядок он не проверяет. Единственная новая мутация на этот предмет (CUTFLAG-a-cut-chunk-is-the-mildest-thing-that-can-happen) тоже бьёт по членству — её edit УДАЛЯЕТ строку FlagAttemptTimeout целиком. Значит вся содержательная часть правки (какой флаг тяжелее какого) не пинована ничем, и решение, которое пак объявляет ключевым для паспорта главы, любая следующая смена может переставить бесшумно. Отдельно: обратной проверки — «каждый отранжированный флаг движок умеет выдать» — тоже нет, и именно поэтому мёртвый FlagConnectionLost из находки №5 пр
|
||||
- **улика линзы:** МУТАЦИЯ (посажена, выжила): status.go:449 `FlagCancelled: 8` → `FlagCancelled: 0` — остановленная позиция становится ХУДШЕЙ проблемой главы, обгоняя жёсткий отказ, то есть ровно то, что комментарий на :443-448 объявляет недопустимым. `go test ./internal/pipeline/` → ok textmachine/backend/internal/pipeline 34.853s. Ни один тест не прочёл значение. Файл восстановлен, cmp: RESTORED-OK. Тело объявленной мутации (cmd/tmmutate/mutations.json), доказывающее, что пин целит в членство, а не в значение: edits[0] = {"find": "\tFlagDecodeError: 4,\n\tFlagAttemptTimeout: 4,\n", "replace": "\tFlagDecodeError: 4,\n"} — строка удаляется, и её ловит проверка «объявлен, но не отранжирован».
|
||||
|
||||
### P2 · `круг8#1` — Строка оператора о повторной оплате банка называет ЛОЖНУЮ причину, когда прежняя строка была сожжённой
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Причина названа ПАРОЙ, а не одной из двух: «либо композиция партий изменилась, либо прежние строки записали деньги без результата и невоспроизводимы». Код не может различить эти случаи, а сообщение, выбирающее один, неверно в половине из них — запрет `D39.93` п.2. Обе ветки оставляют оператору одно и то же действие, поэтому назвать обе ничего не стоит, а назвать одну стоит правды. ⚠ Гейт `TestEveryOperatorMessageIsCatalogued` поймал правку — голден обновлён ОДНОЙ строкой (126 → 126), и это ОБСЛУЖИВАНИЕ по `D39.183`: смена вызвана заказанной правкой формулировки, объявляю отдельно
|
||||
- **семья:** читатели контракта ⟨вписано амендментом 10.09⟩
|
||||
- **где:** `internal/pipeline/terminologist.go:394` (чтение) → `:478` (строка)
|
||||
- **как:** Предупреждение обязано различать «прежние чекпойнты не нашлись из-за смены композиции партий» и «прежняя строка невоспроизводима, потому что она сожжена». Причина у сообщения одна, а фактов два.
|
||||
- **тяжесть после опровержения:** слово оператору · **источник:** пере-проверка знаменателя 10.09 ⟨позиция ВПИСАНА АМЕНДМЕНТОМ, в наряде её не было⟩
|
||||
- **в чём дефект:** `HasCheckpointForStage` — ЧЕТВЁРТЫЙ читатель существования чекпойнта, которого счёт по `GetCheckpoint` не видел. Он спрашивает «платила ли книга за эту стадию когда-либо», и сожжённая строка отвечает «да» — законно, деньги были потрачены. Но строка на `:478` объясняет оператору повторную оплату так: «those earlier calls were made under a batch composition the already-banked filter does not reproduce, so their checkpoints could not be found». Если единственная прежняя строка сожжена, чекпойнт не нашёлся НЕ из-за композиции: он невоспроизводим по построению. Сообщение, лгущее о СВОЕЙ причине, запрещено `D39.93` п.2 — тем самым пунктом, который пак цитирует сам (`cutcall.go:158`).
|
||||
- **почему это НЕ ломает знаменатель:** замер: `grep -n paidBefore internal/pipeline/*.go` = 3 хита (чтение, комментарий, один `if`). Гейтит РОВНО строку лога, ничего не покупает и никакого бюджета не охраняет ⇒ денежных читателей по-прежнему три, основание наряда цело. Правило остановки не сработало и не должно было.
|
||||
- **воспроизведение:** книга с единственной сожжённой банковой строкой + свежий проход ⇒ `reconsolidated=true` с текстом про композицию.
|
||||
|
||||
### P2 · `bank-and-counters#6` — Строка «asked N times» невидима там, где колонку читают: errTail режет на 120 байтах, и приписка стоит в отрезаемом хвосте
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Формулировка вынесена в именованную `cutErrLine` (`cutcall.go`), потому что оба её свойства несущие и с места вызова не видны. Приписка «asked N times» переставлена В НАЧАЛО: колонка оператора ограничена 120 байтами, транспортная ошибка перед ней регулярно длиннее, поэтому приписка в хвосте не доходила НИКОГДА. Пин `TestTheAskedNTimesNoteReachesTheReader`; мутация `CUTLINE-the-note-goes-back-to-the-end` RED
|
||||
- **где:** `internal/pipeline/cutcall.go:122 против cmd/tmctl/render.go:512-528`
|
||||
- **как:** ОБЪЕДИНЕНО с retry-loop#5. Приписка «asked N times» стоит в КОНЦЕ err, а errTail режет на 120 байтах ⇒ до оператора не доходит никогда. Плюс перевод строки от errors.Join печатает одну строку отчёта двумя. Ставить приписку в НАЧАЛО и убирать перевод строки.
|
||||
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
|
||||
|
|
@ -338,6 +490,8 @@
|
|||
### P2 · `cancelled-mark#5` — Комментарий у defer называет носителем банковские батчи, которые в runStage не входят
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Из перечня носителей у `defer` убраны банковые батчи: в `runStage` они не входят, и комментарий врал о знаменателе. Осталось то, что там действительно есть, — хоп и под-шаг ремонта
|
||||
- **где:** `internal/pipeline/stagerun.go:143-144`
|
||||
- **как:** Комментарий у defer называет носителем банковские батчи, которые в runStage НЕ входят. Убрать их из перечня — иначе комментарий врёт о знаменателе дверей.
|
||||
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
|
||||
|
|
@ -349,6 +503,8 @@
|
|||
### P2 · `money-predicate#5` — Лог сожжённого ключа называет «оплаченным» чекпойнт на $0
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Строка ожога переформулирована: «the key of this attempt is SPENT … cannot be replayed» вместо «attempt was paid for». Ключ потрачен в любом случае, а стоил он законно нуля, когда провайдер вызов не подтвердил; «оплачен» над `cost_usd=0.000000` читается ночью в логе как биллинговый баг
|
||||
- **где:** `internal/pipeline/stagerun.go:496-498`
|
||||
- **как:** Строка «attempt was paid for» печатается над cost_usd=0.000000. Переформулировать: «ключ потрачен» вместо «оплачен», и печатать сумму как есть.
|
||||
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
|
||||
|
|
@ -360,6 +516,8 @@
|
|||
### P2 · `money-predicate#6` — AfterHeaders=true для 1xx и для незакрытого блока заголовков — оператору сообщают об ответе, которого не было
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Выбран второй путь наряда — описать поле тем, чем оно является. `AfterHeaders` теперь документирован как ПЕРВЫЙ БАЙТ ответа (`GotFirstResponseByte`), а не как «ответ начал приходить», с явным перечнем того, для чего он срабатывает и подтверждением ничего не значит: 1xx, незакрытый блок заголовков, мусор сломанного прокси. Денежный вопрос отвечает `Billable`, который дополнительно требует 2xx-объекта в руках, и этот раздел теперь закреплён пином `TestResponseBytesWithoutAReplyAreNotAPurchase` (см. `money-predicate#3`)
|
||||
- **где:** `internal/llm/attemptcut.go:79-80, 211`
|
||||
- **как:** AfterHeaders=true для 1xx и для незакрытого блока заголовков — оператору сообщают об ответе, которого не было. Либо считать только 2xx-объект, либо переименовать поле в лог-строке.
|
||||
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
|
||||
|
|
@ -371,6 +529,8 @@
|
|||
### P2 · `retry-loop#4` — Строка операторского журнала утверждает «одна оценка забукана на все», когда забукан НОЛЬ
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Текст говорит о фактически забуканном: при `cost <= 0` — «NOTHING is booked: the provider acknowledged none of them» вместо «ONE estimate is booked». Строка рядом с $0-записью, утверждающая обратное, — предложение, которое надо не поверить, чтобы им пользоваться. Мутация `CUTLINE-the-note-claims-money-that-was-not-booked` RED
|
||||
- **где:** `internal/pipeline/cutcall.go:115-122`
|
||||
- **как:** Строка утверждает «ONE estimate is booked for all of them», когда забукан НОЛЬ (Billable=false). Условие текста должно смотреть на фактическую сумму.
|
||||
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
|
||||
|
|
@ -382,6 +542,8 @@
|
|||
### P2 · `retry-loop#5` — errors.Join кладёт перевод строки в request_log.err: одна строка отчёта печатается ДВУМЯ, а заметка о числе доставок обрезается и до оператора не доходит никогда
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** `errTail` (`cmd/tmctl/render.go`) схлопывает пробельное: `errors.Join` разделяет членов переводом строки, и строка таблицы печаталась ДВУМЯ, ломая выравнивание всего под ней. Пин `TestTheOperatorTailIsOneLineAndKeepsTheNoteThatMatters` — перевод строки поставлен ВНУТРИ границы обрезки, иначе обрезка убрала бы его даром и фикстура прошла бы на рендерере, который ничего не схлопывает. Мутация `CUTLINE-the-tail-splits-across-rows` RED
|
||||
- **где:** `cmd/tmctl/render.go:512-528`
|
||||
- **как:** дубль bank-and-counters#6
|
||||
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
|
||||
|
|
@ -393,6 +555,8 @@
|
|||
### P2 · `transport-and-config#5` — Пол zai замерен по одной модели из двух; вторая (флагман glm-5.1) наследует 35 ток/с без замера и без предупреждения
|
||||
|
||||
- **действие:** ЧИНЮ
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** Замерить вторую модель нельзя — пак $0, платных вызовов ноль. Поэтому объявлено ОТСТУПЛЕНИЕ там, где живёт число (`configs/models.yaml`): пол замерен по glm-5, glm-5.1 наследует его без собственного замера, и названо, почему это принято — направление ошибки безопасно, 35 ниже вендорского дефолта 35.5, то есть даже неверный для 5.1 пол даёт вызову БОЛЬШЕ времени, а не меньше. Назван и способ снять отступление: n≥100 строк request_log по glm-5.1 и её собственный p10
|
||||
- **где:** `configs/models.yaml:85-90`
|
||||
- **как:** Пол zai замерен по одной модели из двух; glm-5.1 наследует 35 ток/с без замера. Либо замерить вторую, либо объявить отступление в комментарии, как сделано у deepseek.
|
||||
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
|
||||
|
|
@ -410,6 +574,8 @@
|
|||
### `transport-and-config#6` — Write-бонд рвёт ВСЮ h2-связь, а не застрявший стрим: соседний вызов, чьё тело уже ушло, умирает вместе с ним
|
||||
|
||||
- **где:** `internal/llm/httpllm.go:140`
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** бонд удалён целиком, ратификация `D39.231` п.2 — оркестратор принял довод против собственного заказа. Предъявлено тройкой замеров на копии: дерево БЕЗ бонда `ok`; бонд ВОЗВРАЩЁН — тоже `ok`, значит батарее он невидим в обе стороны и не держал ничего; контроль — посадка новой мутации RED текстом «the read-idle bound is not on the transport: got 0s want 15s». Два зелёных без третьей строки означали бы «прибор не спросил»
|
||||
- **почему не моё:** Write-бонд рвёт ВСЮ h2-связь (липкая cc.werr), а не застрявший стрим: соседний вызов, чьё тело уже ушло, умирает вместе с ним. Вместе с #2 и #9 это довод СНЯТЬ бонд, а не чинить — но снятие вернёт исходную дыру, и это решение оркестратора.
|
||||
- **в чём дефект:** Ошибка записи липнет к соединению, а не к стриму, и h2 мультиплексирует параллельные стадии одного провайдера в один ClientConn. Один застрявший на записи вызов уносит все остальные in-flight на этом соединении. Деньги при этом не теряются (у соседа нет 2xx-объекта ⇒ Billable=false), но это лишний ретрай и лишняя задержка, и в комментарии к константе размен не назван.
|
||||
- **воспроизведение:** `d=$(mktemp -d) && (cd /home/ubuntu-26/projects/textmachine/backend && tar -cf "$d/t.tar" --exclude=./bin --exclude='./.env*' .) && mkdir "$d/backend" && tar -xf "$d/t.tar" -C "$d/backend" && cp /tmp/claude-1000/-home-ubuntu-26-projects-textmachine/f88e2870-ec2e-48b8-9776-d0370e00bc0b/scratchpad/prob`
|
||||
|
|
@ -417,6 +583,8 @@
|
|||
### `transport-and-config#9` — Для тех тел, которые движок реально шлёт, write-бонд не может выстрелить — обещание «дедлайн был инертен» верно на два порядка выше нашего размера
|
||||
|
||||
- **где:** `internal/llm/httpllm.go:101-106`
|
||||
- **исход:** сделано ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** тот же предмет, снят вместе с механизмом. Носитель класса «наш дедлайн инертен на h2» остаётся строкой бэклога **373**: удалён МЕХАНИЗМ, который класс не закрывал, а не сам класс
|
||||
- **почему не моё:** Для тел, которые движок реально шлёт, бонд выстрелить не может: обещание «дедлайн был инертен» верно на два порядка выше нашего размера. Кластер с #2 и #6.
|
||||
- **в чём дефект:** Запись блокируется, только когда тело перестаёт помещаться в буферы сокета; до этого RoundTrip дописывает запрос, cleanupWriteRequest снова слушает ctx, и НАШ дедлайн работает штатно. Реальные тела запроса у нас — десятки KiB. Значит зафиксированный в комментарии класс зависаний достижим у нас только при теле в единицы MiB, чего пайплайн не отправляет; констатация полезна, чтобы следующая смена не считала бонд активной защитой боевых вызовов.
|
||||
- **воспроизведение:** `d=$(mktemp -d) && (cd /home/ubuntu-26/projects/textmachine/backend && tar -cf "$d/t.tar" --exclude=./bin --exclude='./.env*' .) && mkdir "$d/backend" && tar -xf "$d/t.tar" -C "$d/backend" && cp /tmp/claude-1000/-home-ubuntu-26-projects-textmachine/f88e2870-ec2e-48b8-9776-d0370e00bc0b/scratchpad/prob`
|
||||
|
|
@ -424,12 +592,16 @@
|
|||
### `критик#3` — Ранг флага решили, а ВЕРДИКТ главы — нет: одна остановка делает главе «attention», две — «fail»
|
||||
|
||||
- **где:** `internal/pipeline/status.go:716, :735, :737`
|
||||
- **исход:** пинг ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** у оркестратора, заведено строкой бэклога **374** (`D39.231` п.3) — вердикт ГЛАВЫ по остановленной позиции, продуктовое решение. ⚠ Исчезнет само, если денежную половину пака урежут: следствие класса `cancelled`
|
||||
- **почему не моё:** Одна остановка делает главе вердикт «attention», две — «fail». Ранг флага это не лечит: вердикт считается по СЧЁТУ флагов, а не по их тяжести. Что должна показывать глава, над которой нажали стоп, — продуктовое решение.
|
||||
- **в чём дефект:** Пак сам сформулировал риск в комментарии к рангу (status.go:443-448): «a passport that reported «cancelled» as a chapter's worst problem would hide the durable finding behind a transient one» — и закрыл его РАНЖИРОВАНИЕМ (FlagCancelled: 8, последний перед unknown). Но ранг влияет только на выбор WorstFlagReason (status.go:717-718). Счётчик UnitsFlagged инкрементится для ЛЮБОГО ChunkFlagged без взгляда на причину (:716), а вердикт главы считается по СЧЁТУ, а не по тяжести: `case p.UnitsFlagged >= 2: fail` / `== 1: attention`. Итог: оператор жмёт стоп в момент, когда в полёте две единицы одной главы, и паспорт объявляет главу проваленной — при том что следующий резюм обе строки перезапишет. На
|
||||
|
||||
### `критик#4` — Строка `cancelled` черновой стадии читается как ВЫПАВШИЙ ЧЛЕН редакторской единицы — контент-вердикт, вынесенный по факту нажатия стопа
|
||||
|
||||
- **где:** `internal/pipeline/status.go:538-543 → export.go:327`
|
||||
- **исход:** пинг ⟨вписано амендментом 10.09⟩
|
||||
- **предъявлено:** у оркестратора, заведено строкой бэклога **375** (`D39.231` п.3) — семантика экспорта. ⚠ Та же оговорка
|
||||
- **почему не моё:** memberDrops опознаёт выпавшего члена по (черновая стадия && flagged) без взгляда на причину, поэтому строка `cancelled` читается как КОНТЕНТ-вердикт. Семантика экспорта — не моё решение.
|
||||
- **в чём дефект:** memberDrops — по собственной шапке «the SINGLE definition of the c-lite rule «a dropped member flags its unit»» — опознаёт выпавшего члена ровно по паре (стадия черновая) && (disposition == flagged), без взгляда на причину. Новая строка cancelled ровно такова. Следствие: остановка над черновиком одного члена помечает ВСЮ редакторскую единицу как «член выброшен» (export.go:327 droppedAny → DispFlagged), то есть выносится вердикт о ТЕКСТЕ там, где с текстом ничего не случилось. Это тот же самый частично правленный носитель, что в находке №2, в третьем его месте, и запрет «флаг, лгущий о своей причине» пак цитирует сам (cutcall.go:158, D39.93 п.2). Ни линз, ни тестов, ни мутаций на memberDrops
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue