scheduler.py
16.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
"""U-032: the scheduler 04-P/01-M/06-验收与测试锚点.md repeatedly presume but
no Unit before this one ever built. REQ-09's boundary case ("多个任务同时有
一个 blocked_pending_human、其余 queued 时,调度器应继续调度 queued 任务")
and 01-M's own indexing rationale ("索引...用于调度器扫描") both assume an
autonomous dispatcher exists; before this it didn't.
`org_node_lifecycle._check_concurrency()` (U-010) already gates a *single*
trigger call against `max_concurrent_goals`, but nothing ever called it
across a real queue of multiple Goals, and `e2e_pipeline.run_end_to_end()`
(U-014) drives exactly one task, synchronously, and was only ever called
from tests.
Found and closed in the same pass (2026-08-02): `run_end_to_end()` itself
deliberately stops at 'validating' with a computed-but-unapplied verdict
("Criteria 4-7 ... explicitly out of this Unit's scope" per its own
docstring) -- a scheduler whose job is "drive Goals to completion" can't
leave every task sitting at 'validating' forever, so `finish_task_lifecycle()`
below applies the PASS -> `close_task_done` / FAIL -> `org_level_loop.
run_fix_loop` step `run_end_to_end` always left undone, without changing
`run_end_to_end`'s own tested behavior or scope.
Threading model: each worker thread opens its OWN sqlite connection
(`schema.connect(db_path)`) rather than sharing one `conn` object across
threads -- U-027 already found the hard way (SIGSEGV) that a shared sqlite3
connection needs a hand-held lock around every operation including close();
replicating that discipline here would just serialize all DB work anyway and
defeat the point of concurrency. Each connection sets a `busy_timeout` so
concurrent writers on the shared file retry instead of raising "database is
locked".
**Second gap found and closed in the same pass (2026-08-02, PH4-closure
audit before starting PH5)**: `work_node_run.CorruptPersistedStateError`
(REQ-06 failure), `work_graph_execute.ClassifierBlockedError` (REQ-07
failure), and `work_node_run.UsageLimitWaitTimeoutError` (REQ-14 failure)
were each correctly raised and each correctly marked their own
`work_node_execution.status='blocked'` (the last one via U-031's catch-all
in `work_node_run.run_work_node`) -- but nothing ever caught them to apply
the *task*-level consequence 06-验收与测试锚点.md's failure text for all
three literally names: `task.status='blocked_pending_human'` with a specific
`blocked_reason`. `work_node_run.py`'s own docstring already flagged this as
a deliberate layering choice ("callers should route it to
task.status='blocked_pending_human'") -- but no caller ever did. This is
exactly the layer `budget_guardrail.check_budget()` already does the
equivalent translation at for its own case (REQ-10), so `_block_task()`
below follows that same established pattern (UPDATE + `task_status_changed`
execution_event) rather than inventing a new one.
**U-035 self-target audit trail (2026-08-02)**: resolved a real conflict
between supervisor-auto-rollback-design.md §1 (Deploy Supervisor triggers on
"self-target + Validator PASS + merge", no bug_record involved) and 03-C
§3.11/04-P §4.6's literal text (gated on an *existing*
`bug_record.root_cause_category=core_engine`) -- confirmed with the user
that a plain goal-type self-improvement task (task_create.py: `c_task_create`
only ever makes task_type='goal') never gets a bug_record on its happy path
at all, so the literal gate would silently defeat PH5's entire purpose for
that whole task class. Resolution (user-confirmed): trigger on self-target+
PASS alone; `bug_record.record_core_engine_change()` is created as an
audit-trail byproduct of that trigger, not a precondition for it. `deployment`
row creation itself (the actual hand-off) stays in U-037's `c_deploy_
supervisor_stage` as its own Done criterion already specifies -- this module
only detects + records, it doesn't stage a candidate. `p_deploy_supervisor_
blue_green` (04-P §4.6) never touches `task.status` at all -- it's a fully
separate state machine on `deployment`, matching supervisor-auto-rollback-
design.md §2's "no shared failure domain" between Deploy Supervisor and the
Graph Engine -- so `close_task_done()` still runs unconditionally on PASS
(the Coder's work IS genuinely complete and merged), the self-target check
is a purely additive side effect alongside it, not a replacement for it.
"""
import json
import uuid
import concurrent.futures
from src import (
bug_record, core_engine_classify, e2e_pipeline, org_level_loop, org_loop_route, paths, schema,
settings_config, task_create, work_graph_execute, work_graph_orchestrate, work_node_run,
)
# Rationale: see module docstring's "Threading model" section (lines 21-28) above.
_BUSY_TIMEOUT_MS = 30000
_FIX_LOOP_TIMEOUT = 1200
_BLOCKED_REASON_FOR_EXCEPTION = (
(work_node_run.CorruptPersistedStateError, "corrupt_persisted_state"),
(work_graph_execute.ClassifierBlockedError, "classifier_blocked"),
(work_node_run.UsageLimitWaitTimeoutError, "usage_limit_wait_timeout"),
(work_graph_orchestrate.ConsistencyCheckFailedError, "prd_consistency_check_failed"),
(e2e_pipeline.PlannerReviewLoopLimitReachedError, "planner_review_loop_limit_reached"),
)
_ROUTABLE_EXCEPTIONS = tuple(exc for exc, _ in _BLOCKED_REASON_FOR_EXCEPTION)
def _blocked_reason_for(exc):
"""2026-08-07: `ConsistencyCheckFailedError` used to be raised only by
the PRD-internal-contradiction check, but commit 07a0a27 (2026-08-06)
generalized the same exception/retry machinery to the test_script
mechanical compile-check too -- without this, both show up in the
console as the same misleading 'prd_consistency_check_failed' label
(see docs/todo/validator-test-script-narrative-output-issue.md), even
when the real failure has nothing to do with the PRD.
ponytail: distinguishes by substring-matching the failing node's LLM-
chosen node_id (e.g. 'n_test_script'/'validator_test_script') rather
than a real typed discriminator threaded through verify_fn -- good
enough because every real graph_manifest observed so far names its
test_script node descriptively; ceiling is a node named ambiguously
silently falling back to the PRD label. Upgrade path if that's ever
wrong in practice: have verify_fn return a third 'kind' value instead
of inferring from node_id text."""
if isinstance(exc, work_graph_orchestrate.ConsistencyCheckFailedError):
if "test_script" in (exc.node_id or ""):
return "test_script_verification_failed"
return "prd_consistency_check_failed"
for exc_type, reason in _BLOCKED_REASON_FOR_EXCEPTION:
if isinstance(exc, exc_type):
return reason
raise AssertionError("no blocked_reason registered for " + type(exc).__name__)
def _block_task(conn, task_id, blocked_reason, detail=None):
"""`detail` (2026-08-03): the real exception message, when there is one --
`blocked_reason` itself stays a short routing code (budget_guardrail's
auto-resume query matches on its exact value, e.g. 'budget_exceeded'), so
this doesn't touch it. Without `detail` an unroutable failure (anything
not in `_ROUTABLE_EXCEPTIONS`) landed as a bare 'execution_error' with
zero diagnostic content anywhere -- found for real via the console's
manual Start button on a freshly created project (see
project_scaffold.py's agent-definitions fix, same incident)."""
task_row = conn.execute("SELECT project_id, status FROM task WHERE task_id = ?", (task_id,)).fetchone()
payload = {"from_status": task_row[1], "to_status": "blocked_pending_human"}
if detail:
payload["detail"] = detail
conn.execute(
"UPDATE task SET status = 'blocked_pending_human', blocked_reason = ?, "
"updated_at = CURRENT_TIMESTAMP WHERE task_id = ?",
(blocked_reason, task_id),
)
conn.execute(
"INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) "
"VALUES (?, ?, ?, 'task_status_changed', ?)",
(str(uuid.uuid4()), task_row[0], task_id, json.dumps(payload)),
)
conn.commit()
def _record_core_engine_audit_if_self_target(conn, task_id, target_folder_path, run_result):
"""U-035 (05 §5.8): additive side effect on a self-target task's PASS
closure -- see module docstring's "self-target audit trail" note for
why this doesn't gate close_task_done, just follows it."""
if not paths.is_self_target(target_folder_path):
return
code_diff_id = run_result.get("code_artifact_ids", {}).get("code_diff")
if not code_diff_id:
return
diff_text = e2e_pipeline._read_artifact_content(conn, target_folder_path, code_diff_id)
changed_files = core_engine_classify.parse_changed_files_from_diff(diff_text)
if core_engine_classify.classify_core_engine_change(changed_files):
bug_record.record_core_engine_change(
conn, task_id,
"self-target change touched core-engine files: " + ", ".join(sorted(set(changed_files))),
)
def finish_task_lifecycle(conn, run_result, target_folder_path, goal_text):
"""PASS -> `close_task_done`; FAIL/INCONCLUSIVE -> `org_level_loop.
run_fix_loop` (root-cause judge + reroute + fix + reverify, U-017).
Adds 'final_status' to `run_result` and returns it.
2026-08-12 (Org Graph unification Phase 2c): the FAIL/INCONCLUSIVE
branch below is effectively dead code for tasks going through the
current e2e_pipeline.py flow -- `validate_implementation_with_own_
graph()`'s verdict is now a self-planned node with `judgment.kind=
'mechanical'`/`on_fail.action='dynamic_loop_back'` (see that function's
own docstring), which retries internally via work_graph_orchestrate.py's
Reflection-review + [Fix-Skill] + loop-back machinery; a normal return
from that function can therefore only ever carry verdict='PASS' -- a
goal that can't resolve within the retry budget raises
ConsistencyCheckFailedError instead, which routes to
blocked_pending_human upstream of this function ever being called with
a non-PASS verdict. Kept, not deleted, per minimal-change philosophy
(a caller using the older, still-live `validate_implementation()`/
hardcoded-manifest path -- none currently do -- would still need it)."""
task_id = run_result["task_id"]
verdict = run_result["verdict"]["verdict"]
if verdict == "PASS":
org_loop_route.close_task_done(conn, task_id)
_record_core_engine_audit_if_self_target(conn, task_id, target_folder_path, run_result)
run_result["final_status"] = "done"
return run_result
benchmark_id = run_result["doc_artifact_ids"]["benchmark"]
benchmark_content = e2e_pipeline._read_artifact_content(conn, target_folder_path, benchmark_id)
fix_result = org_level_loop.run_fix_loop(
conn, task_id, target_folder_path,
bug_description="Validator reported {} for goal: {}".format(verdict, goal_text),
context=run_result["verdict"]["raw_response"],
fix_prompt=e2e_pipeline._READY_PROMPT,
verify_prompt=e2e_pipeline._VALIDATOR_PROMPT.format(
goal_text=goal_text, benchmark_content=benchmark_content
),
timeout=_FIX_LOOP_TIMEOUT,
)
run_result["final_status"] = fix_result["final_status"]
return run_result
def run_goal_to_completion(db_path, target_folder_path, goal_text):
"""One Goal, its own sqlite connection, start to a terminal task.status
(done or blocked_pending_human). This is the unit of work each scheduler
worker thread runs.
Creates the task itself (rather than delegating to e2e_pipeline.
run_end_to_end) so task_id is in scope even if e2e_pipeline.
run_task_lifecycle raises partway through -- needed to route
CorruptPersistedStateError/ClassifierBlockedError/UsageLimitWaitTimeoutError
to task.status='blocked_pending_human' (see module docstring's "Second
gap" note).
**2026-08-04**: any OTHER exception (e.g. work_graph_execute.
WorkGraphExecuteError from a real API hiccup -- found for real: a
"Response stalled mid-stream" error crashed the calling process
entirely) used to propagate uncaught, same as before this note --
`run_scheduler_once`'s own try/except only stops it from taking down
OTHER goals in the same batch, it does NOT apply the task-level
blocked_pending_human consequence either, so the task itself was left
stuck in limbo: not running, not blocked, `blocked_reason` still NULL,
nothing a human could `manual_resume` from the console. This mirrors
the exact fallback `task_execution._mark_blocked()` already has for its
own (Start-button) entry point -- both entry points now guarantee a
task always lands on a real terminal-ish status with a real error
detail, never a caller-crashes-and-the-task-is-just-stuck outcome.
"""
# check_same_thread=False (2026-08-06, F): this conn can reach
# work_graph_orchestrate.run_graph() via finish_task_lifecycle ->
# org_level_loop.run_fix_loop -> root_cause_trace.
# judge_root_cause_via_work_graph, whose 3-perspective layer runs on
# its OWN ThreadPoolExecutor worker threads (G), not this function's
# thread -- a plain schema.connect() here would raise "SQLite objects
# created in a thread can only be used in that same thread" the first
# time a real multi-node concurrent layer actually runs (previously
# never exercised in production: every graph_manifest before F was
# either single-node or a strictly serial chain).
conn = schema.connect(db_path, check_same_thread=False)
conn.execute("PRAGMA busy_timeout = {}".format(_BUSY_TIMEOUT_MS))
try:
project_id = conn.execute(
"SELECT project_id FROM project WHERE target_folder_path = ?", (target_folder_path,)
).fetchone()[0]
task_id = task_create.create_task(conn, project_id, goal_text)["task_id"]
try:
result = e2e_pipeline.run_task_lifecycle(conn, task_id, target_folder_path, goal_text)
except _ROUTABLE_EXCEPTIONS as e:
blocked_reason = _blocked_reason_for(e)
_block_task(conn, task_id, blocked_reason, detail=str(e))
return {"task_id": task_id, "final_status": "blocked_pending_human", "blocked_reason": blocked_reason}
except Exception as e:
_block_task(conn, task_id, "execution_error", detail=str(e))
return {"task_id": task_id, "final_status": "blocked_pending_human", "blocked_reason": "execution_error"}
return finish_task_lifecycle(conn, result, target_folder_path, goal_text)
finally:
conn.close()
def run_scheduler_once(db_path, goals, max_workers=None):
"""`goals`: [(target_folder_path, goal_text), ...], one entry per Goal to
dispatch this round. Runs up to `max_workers` concurrently (default:
`org_concurrency_config.max_concurrent_goals`, the same knob
`org_node_lifecycle._check_concurrency` reads -- read fresh here so a
config change takes effect on the next scheduler round without a
restart); goals beyond that queue inside the executor and start as slots
free up, which is what actually makes "raise max_concurrent_goals -> more
Goals genuinely run in parallel" (U-032's Done line) true, rather than
something only exercised by an exception-driven retry loop.
A goal that raises (including `LoopLimitReachedError` once `loop_round`
hits 3 -> `blocked_pending_human`, which IS the expected outcome of that
path, not a scheduler bug) doesn't stop the others -- 04-P's "调度器继续
执行队列中其他任务" applies to a raised exception in one goal exactly the
same as it does to one that's already `blocked_pending_human` going in.
Returns a list of {'goal_text', 'result'} or {'goal_text', 'error'} in
`goals` order.
"""
if max_workers is None:
conn = schema.connect(db_path)
try:
config = settings_config.get_concurrency_config(conn)
finally:
conn.close()
max_workers = config["max_concurrent_goals"] if config else 1
results = [None] * len(goals)
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {
pool.submit(run_goal_to_completion, db_path, target_folder_path, goal_text): i
for i, (target_folder_path, goal_text) in enumerate(goals)
}
for future in concurrent.futures.as_completed(futures):
i = futures[future]
goal_text = goals[i][1]
try:
results[i] = {"goal_text": goal_text, "result": future.result()}
except Exception as e:
results[i] = {"goal_text": goal_text, "error": str(e)}
return results