@ludi-uni/ludi-agent-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/AGENTS.md +55 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/adapters/codex/README.md +24 -0
  5. package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
  6. package/adapters/pi/README.md +88 -0
  7. package/adapters/pi/browser/agent-browser.mjs +193 -0
  8. package/adapters/pi/lib/invoke.mjs +55 -0
  9. package/adapters/pi/lib/list-models.mjs +29 -0
  10. package/adapters/pi/lib/settings-proposal.mjs +34 -0
  11. package/adapters/pi/lib/subagent.mjs +175 -0
  12. package/adapters/pi/loop-guard/index.js +51 -0
  13. package/adapters/pi/maintenance-policy.json +36 -0
  14. package/adapters/pi/mcp.template.json +4 -0
  15. package/adapters/pi/model-catalog.json +97 -0
  16. package/adapters/pi/models.json +13 -0
  17. package/adapters/pi/models.local.example.json +14 -0
  18. package/adapters/pi/orchestrator-ext/command.mjs +14 -0
  19. package/adapters/pi/orchestrator-ext/index.js +150 -0
  20. package/adapters/pi/settings.template.json +7 -0
  21. package/adapters/pi/shell-gate/index.js +70 -0
  22. package/adapters/pi/sync-pi.ps1 +137 -0
  23. package/agents/README.md +26 -0
  24. package/agents/browser.md +64 -0
  25. package/agents/coder.md +31 -0
  26. package/agents/orchestrator.md +37 -0
  27. package/agents/reviewer.md +32 -0
  28. package/agents/scout.md +35 -0
  29. package/agents/tester.md +28 -0
  30. package/agents/visual.md +28 -0
  31. package/context-pack/SPEC.md +101 -0
  32. package/context-pack/context-pack.schema.json +79 -0
  33. package/context-pack/examples/example-fix.md +44 -0
  34. package/docs/architecture.md +55 -0
  35. package/docs/migration-from-codex-setting.md +44 -0
  36. package/docs/model-maintenance.md +401 -0
  37. package/docs/orchestrator.md +155 -0
  38. package/docs/phase2-report.md +39 -0
  39. package/docs/roadmap.md +27 -0
  40. package/docs/third-party.md +15 -0
  41. package/lib/agents.mjs +79 -0
  42. package/lib/context-pack.mjs +215 -0
  43. package/lib/job.mjs +312 -0
  44. package/lib/language-policy.mjs +27 -0
  45. package/lib/maintenance-exec.mjs +377 -0
  46. package/lib/maintenance-runner.mjs +266 -0
  47. package/lib/maintenance.mjs +422 -0
  48. package/lib/normalize.mjs +101 -0
  49. package/lib/observe/differ.mjs +185 -0
  50. package/lib/observe/observation.mjs +147 -0
  51. package/lib/observe/observers.mjs +134 -0
  52. package/lib/observe/sources.mjs +154 -0
  53. package/lib/orchestrator/activity.mjs +249 -0
  54. package/lib/orchestrator/api.mjs +151 -0
  55. package/lib/orchestrator/contract.mjs +68 -0
  56. package/lib/orchestrator/escalation.mjs +84 -0
  57. package/lib/orchestrator/evaluator.mjs +92 -0
  58. package/lib/orchestrator/failures.mjs +88 -0
  59. package/lib/orchestrator/health.mjs +53 -0
  60. package/lib/orchestrator/orchestrator.mjs +483 -0
  61. package/lib/orchestrator/permissions.mjs +64 -0
  62. package/lib/orchestrator/planner.mjs +194 -0
  63. package/lib/orchestrator/policy.mjs +134 -0
  64. package/lib/orchestrator/router.mjs +45 -0
  65. package/lib/orchestrator/runner.mjs +278 -0
  66. package/lib/orchestrator/shell-policy.mjs +52 -0
  67. package/lib/orchestrator/store.mjs +581 -0
  68. package/lib/orchestrator/task-store.mjs +79 -0
  69. package/lib/orchestrator/turn-budget.mjs +63 -0
  70. package/lib/orchestrator/worktree.mjs +72 -0
  71. package/lib/pipeline.mjs +279 -0
  72. package/lib/registry.mjs +63 -0
  73. package/lib/resolve.mjs +35 -0
  74. package/lib/routing.mjs +137 -0
  75. package/lib/telemetry.mjs +222 -0
  76. package/mcp/README.md +11 -0
  77. package/mcp/servers.json +13 -0
  78. package/orchestration/decision-policy.json +66 -0
  79. package/package.json +56 -0
  80. package/routing/README.md +24 -0
  81. package/routing/routing.json +81 -0
  82. package/routing/routing.schema.json +66 -0
  83. package/rules/README.md +10 -0
  84. package/rules/common.md +52 -0
  85. package/rules/loop-prevention.md +15 -0
  86. package/rules/repo-local.md +6 -0
  87. package/scripts/check-environment.ps1 +22 -0
  88. package/scripts/context-pack.mjs +17 -0
  89. package/scripts/e2e-investigate-repro.mjs +66 -0
  90. package/scripts/model-maintenance-job.mjs +59 -0
  91. package/scripts/observe-models.mjs +97 -0
  92. package/scripts/orchestrate.mjs +137 -0
  93. package/scripts/reevaluate-models.mjs +95 -0
  94. package/scripts/report-model-maintenance.mjs +70 -0
  95. package/scripts/resolve-capabilities.mjs +39 -0
  96. package/scripts/run-pipeline.mjs +56 -0
  97. package/scripts/sync-agents-md.ps1 +10 -0
  98. package/scripts/validate.mjs +71 -0
  99. package/skills/README.md +14 -0
  100. package/skills/pi-workflow/SKILL.md +26 -0
  101. package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
  102. package/skills/pi-workflow/references/research.md +14 -0
  103. package/skills/pi-workflow/references/review.md +11 -0
  104. package/skills/pi-workflow/references/visual-work.md +14 -0
  105. package/skills/project-management/SKILL.md +106 -0
  106. package/skills/project-management/references/operations.md +52 -0
  107. package/skills/visual-verification/SKILL.md +88 -0
  108. package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
  109. package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
  110. package/skills/visual-verification/scripts/common.ps1 +387 -0
  111. package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
  112. package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
  113. package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
  114. package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
  115. package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
  116. package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
  117. package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
  118. package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
  119. package/skills/visual-verification/scripts/record-av.ps1 +102 -0
  120. package/skills/visual-verification/scripts/record.ps1 +72 -0
  121. package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
  122. package/skills/visual-verification/scripts/waveform.ps1 +450 -0
  123. package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
  124. package/tests/activity.test.mjs +252 -0
  125. package/tests/attempt-budget.test.mjs +102 -0
  126. package/tests/browser.test.mjs +121 -0
  127. package/tests/context-pack.test.mjs +98 -0
  128. package/tests/dirty-gate.test.mjs +211 -0
  129. package/tests/e2e-browser.mjs +66 -0
  130. package/tests/e2e-real-orchestrator-resume.mjs +101 -0
  131. package/tests/e2e-real-orchestrator.mjs +41 -0
  132. package/tests/e2e-real-pi.mjs +27 -0
  133. package/tests/e2e-real-tool-orchestrator.mjs +66 -0
  134. package/tests/fixtures/browser-page/index.html +20 -0
  135. package/tests/fixtures/maintenance/availability.txt +5 -0
  136. package/tests/fixtures/maintenance/catalog.json +74 -0
  137. package/tests/fixtures/maintenance/events.json +13 -0
  138. package/tests/fixtures/math-repo/README.md +3 -0
  139. package/tests/fixtures/math-repo/package.json +7 -0
  140. package/tests/fixtures/math-repo/src/math.js +11 -0
  141. package/tests/fixtures/math-repo/test/math.test.js +7 -0
  142. package/tests/fixtures/observe/announcements.json +8 -0
  143. package/tests/fixtures/orch-concurrent-child.mjs +44 -0
  144. package/tests/fixtures/orch-persist-child.mjs +61 -0
  145. package/tests/job.test.mjs +230 -0
  146. package/tests/kit.test.mjs +79 -0
  147. package/tests/language-policy.test.mjs +93 -0
  148. package/tests/loop-guard.test.mjs +60 -0
  149. package/tests/maintenance-exec.test.mjs +218 -0
  150. package/tests/maintenance-runner.test.mjs +222 -0
  151. package/tests/maintenance.test.mjs +195 -0
  152. package/tests/observe.test.mjs +283 -0
  153. package/tests/observer-registry.test.mjs +157 -0
  154. package/tests/orchestrator-cleanup.test.mjs +358 -0
  155. package/tests/orchestrator-command.test.mjs +14 -0
  156. package/tests/orchestrator-persist.test.mjs +375 -0
  157. package/tests/orchestrator-tools.test.mjs +215 -0
  158. package/tests/orchestrator.test.mjs +396 -0
  159. package/tests/package.test.mjs +37 -0
  160. package/tests/pipeline.test.mjs +239 -0
  161. package/tests/planner-classification.test.mjs +81 -0
  162. package/tests/planner-split.test.mjs +67 -0
  163. package/tests/qoder-observer.test.mjs +266 -0
  164. package/tests/reassign-progression.test.mjs +104 -0
  165. package/tests/retry-escalation.test.mjs +120 -0
  166. package/tests/routing.test.mjs +110 -0
  167. package/tests/sqlite-concurrency.test.mjs +178 -0
  168. package/tests/task-global-e2e.test.mjs +63 -0
  169. package/tests/task-global-failed.test.mjs +134 -0
  170. package/tests/telemetry.test.mjs +173 -0
  171. package/tests/test-sync-pi.ps1 +56 -0
  172. package/tests/turn-budget.test.mjs +106 -0
@@ -0,0 +1,88 @@
1
+ // Separates "try another model" from "the task itself failed".
2
+ import { classifyBackendFailure } from './health.mjs';
3
+
4
+ export const FAILURE_CLASSES = [
5
+ 'MODEL_FAILURE', 'BACKEND_LIMIT', 'TOOL_FAILURE', 'TEST_FAILURE', 'TIMEOUT',
6
+ 'NO_PROGRESS_TIMEOUT', 'PROGRESS_TIMEOUT',
7
+ 'POLICY_BLOCK', 'USER_DECISION_REQUIRED', 'MALFORMED_RESULT', 'EMPTY_RESPONSE', 'UNKNOWN',
8
+ ];
9
+
10
+ export const REASSIGN_CLASSES = new Set(['MODEL_FAILURE', 'BACKEND_LIMIT']);
11
+
12
+ /**
13
+ * Failure classes that indicate a model/protocol-quality problem rather than a
14
+ * task problem. On these, retrying the SAME model is unlikely to help — advance
15
+ * to the next candidate (or escalate capability) instead of a same-model retry.
16
+ * EMPTY_RESPONSE and turn-limit TIMEOUT are included; MALFORMED_RESULT means the
17
+ * model could not honour the structured-result contract.
18
+ */
19
+ export const PROTOCOL_FAILURE_CLASSES = new Set(['MALFORMED_RESULT', 'EMPTY_RESPONSE', 'TIMEOUT']);
20
+
21
+ /**
22
+ * True when a failure should move to the next model candidate, not retry the same one.
23
+ * A TIMEOUT is only a protocol failure when the model made no real progress
24
+ * (turn limit with ~0 tool calls = the model spun without doing work). A timeout
25
+ * that followed genuine tool progress stays retryable on the same model.
26
+ */
27
+ export function isProtocolFailure(failureClass, run = null) {
28
+ // A no-progress timeout is a model-quality problem -> advance candidate.
29
+ if (failureClass === 'NO_PROGRESS_TIMEOUT') return true;
30
+ // A progress timeout means the model WAS working — it is not a protocol-quality
31
+ // failure; it is recoverable (more turns / a bigger budget may finish it).
32
+ if (failureClass === 'PROGRESS_TIMEOUT') return false;
33
+ if (failureClass === 'TIMEOUT') {
34
+ const toolCalls = run?.child?.toolCalls ?? run?.toolCalls;
35
+ // No progress signal -> treat as protocol failure. Progress -> recoverable.
36
+ if (toolCalls !== undefined && toolCalls !== null) return toolCalls === 0 && !run?.structured && !run?.result?.summary;
37
+ return false; // unknown progress is not evidence of a protocol failure
38
+ }
39
+ return PROTOCOL_FAILURE_CLASSES.has(failureClass);
40
+ }
41
+
42
+ /**
43
+ * Should this failure mark the model as failed for the WHOLE task (across
44
+ * capabilities)? Task-global failures are model/protocol-quality problems that
45
+ * will almost certainly recur if the same canonical model is invoked again under
46
+ * a different capability — so the model is skipped task-wide, not just locally.
47
+ *
48
+ * Global: MALFORMED_RESULT, EMPTY_RESPONSE, no-progress turn-limit TIMEOUT.
49
+ * Not global: transient tool failure, recoverable command error, task-specific
50
+ * implementation/validation failure (feedback can fix those).
51
+ *
52
+ * @param {string} failureClass classified failure
53
+ * @param {object} [telemetry] optional { toolCalls, turns, hasOutput } for the
54
+ * specific invocation — used only to judge TIMEOUT progress; never guessed.
55
+ */
56
+ export function shouldMarkTaskGlobalFailure(failureClass, telemetry = null) {
57
+ if (failureClass === 'MALFORMED_RESULT' || failureClass === 'EMPTY_RESPONSE' || failureClass === 'MODEL_FAILURE') return true;
58
+ if (failureClass === 'NO_PROGRESS_TIMEOUT') return telemetry?.toolCalls === 0 && telemetry?.structuredProgress === false && telemetry?.hasFinalOutput === false;
59
+ // A PROGRESS_TIMEOUT means the model was making real progress — it is NOT a
60
+ // model-quality failure and must NOT be added to taskGlobalFailedModels.
61
+ if (failureClass === 'PROGRESS_TIMEOUT') return false;
62
+ if (failureClass === 'TIMEOUT') {
63
+ // A generic timeout is not sufficient: require turn-limit provenance and
64
+ // explicit evidence of zero tool calls, no structured progress and no final output.
65
+ return telemetry?.turnLimit === true && telemetry?.toolCalls === 0 &&
66
+ telemetry?.structuredProgress === false && telemetry?.hasFinalOutput === false;
67
+ }
68
+ return false;
69
+ }
70
+
71
+ export function classifyRun(run) {
72
+ if (run?.failureClass && FAILURE_CLASSES.includes(run.failureClass)) return run.failureClass;
73
+ const result = run?.result;
74
+ const text = `${run?.error ?? ''} ${result?.summary ?? ''}`;
75
+ if (classifyBackendFailure(text)) return 'BACKEND_LIMIT';
76
+ if (/timed out|timeout|ETIMEDOUT|tool call limit|turn limit/i.test(text)) return 'TIMEOUT';
77
+ if (/POLICY_BLOCK|policy block/i.test(text)) return 'POLICY_BLOCK';
78
+ if (result?.status === 'blocked' || result?.status === 'needs_decision') return 'USER_DECISION_REQUIRED';
79
+ if (!run?.structured && result?.status === 'unknown') return 'MALFORMED_RESULT';
80
+ if (/no bound model|capability/i.test(text)) return 'MODEL_FAILURE';
81
+ const verification = JSON.stringify(result?.verification ?? '');
82
+ if (/test fail|tests failed|assertionerror|npm ERR/i.test(`${text} ${verification}`)) return 'TEST_FAILURE';
83
+ if (/empty model response|empty response|no output|blank response/i.test(text)) return 'EMPTY_RESPONSE';
84
+ if (/tool error|command failed|ludi_exec/i.test(text)) return 'TOOL_FAILURE';
85
+ if (result?.status === 'failed') return 'TEST_FAILURE';
86
+ if (run?.error) return 'MODEL_FAILURE';
87
+ return 'UNKNOWN';
88
+ }
@@ -0,0 +1,53 @@
1
+ // Backend health for one run, and a short-lived cross-run mark when usage is exhausted.
2
+ // There is no permanent blacklist: every record has expiresAt. Unknown durations use the policy TTL.
3
+ import { DEFAULT_POLICY } from './policy.mjs';
4
+
5
+ export function classifyBackendFailure(reason) {
6
+ const t = String(reason ?? '').toLowerCase();
7
+ if (t.includes('usage limit has been reached') || t.includes('usage limit') || t.includes('insufficient_quota')) return 'usage_exhausted';
8
+ if (t.includes('rate limit') || t.includes('too many requests')) return 'rate_limited';
9
+ if (t.includes('temporarily unavailable') || t.includes('service unavailable') || t.includes('overloaded')) return 'temporarily_unavailable';
10
+ return null;
11
+ }
12
+
13
+ export function healthTtlMs(policy, state) {
14
+ const h = policy?.backend_health ?? DEFAULT_POLICY.backend_health;
15
+ if (state === 'usage_exhausted') return h.usage_exhausted_ttl_hours * 3600 * 1000;
16
+ if (state === 'rate_limited') return h.rate_limited_ttl_minutes * 60 * 1000;
17
+ return h.unavailable_ttl_minutes * 60 * 1000;
18
+ }
19
+
20
+ /**
21
+ * skip/report hooks for withEscalation. bindRun(id) before the first model call.
22
+ * Without a session, health lives in this object for the current process only.
23
+ */
24
+ export function createHealthMonitor({ session = null, policy, now = () => new Date().toISOString() } = {}) {
25
+ let runId = null;
26
+ const local = [];
27
+ return {
28
+ bindRun(id) { runId = id; },
29
+ skip(candidate) {
30
+ if (!runId) return null;
31
+ const at = now();
32
+ if (session) {
33
+ const row = session.activeHealth({ provider: candidate.provider, model: candidate.model, runId, now: at });
34
+ return row ? `${row.state} until ${row.expiresAt}` : null;
35
+ }
36
+ const row = local.find(r => r.provider === candidate.provider && r.model === candidate.model && r.expiresAt > at && (r.runId === runId || r.runId === ''));
37
+ return row ? `${row.state} until ${row.expiresAt}` : null;
38
+ },
39
+ report(candidate, reason) {
40
+ const state = classifyBackendFailure(reason);
41
+ if (!state || !runId) return null;
42
+ const ttlMs = healthTtlMs(policy, state);
43
+ const at = now();
44
+ if (session) return session.recordHealth({ provider: candidate.provider, model: candidate.model, state, reason, runId, ttlMs, now: at });
45
+ const expiresAt = new Date(Date.parse(at) + ttlMs).toISOString();
46
+ const rowRun = state === 'usage_exhausted' ? '' : runId;
47
+ const row = { provider: candidate.provider, model: candidate.model, state, reason: String(reason).slice(0, 500), detectedAt: at, retryAfter: expiresAt, expiresAt, runId: rowRun };
48
+ const i = local.findIndex(r => r.provider === row.provider && r.model === row.model && r.runId === rowRun);
49
+ if (i >= 0) local[i] = row; else local.push(row);
50
+ return row;
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,483 @@
1
+ // Orchestration loop: plan -> route -> delegate (bounded concurrency) -> evaluate -> retry / reassign /
2
+ // decide / add work -> integrated report. With a store session the same loop is resumable: task state,
3
+ // counters, decisions and trace are written before and after each unit of work.
4
+ import { planRules, planWithModel, validatePlan } from './planner.mjs';
5
+ import { createTaskStore, createNullProjectStore, newTask, runnableTasks, strandedTasks } from './task-store.mjs';
6
+ import { resolveTaskModels, nextLadderCapability, agentForCapability } from './router.mjs';
7
+ import { evaluateResult } from './evaluator.mjs';
8
+ import { evaluateDecision, decisionKey } from './escalation.mjs';
9
+ import { classifyRun, REASSIGN_CLASSES, isProtocolFailure, shouldMarkTaskGlobalFailure } from './failures.mjs';
10
+ import { declaredMode, accessOf, piToolsForAccess, workspaceOf } from './permissions.mjs';
11
+
12
+ const STOP = new Set(['failed', 'blocked', 'waiting_for_user']);
13
+
14
+ async function makePlan(request, { planner, plan, agents, routing, registry, policy, invoke, cwd, trace, health }) {
15
+ if (plan) return { planner: 'explicit', tasks: plan };
16
+ if (planner === 'model') return planWithModel(request, { agents, routing, registry, policy, invoke, cwd, trace, health });
17
+ return planRules(request, { agents, policy });
18
+ }
19
+
20
+ /** Plan and route only; no agent runs (model planner still calls the orchestrator model once). */
21
+ export async function dryRun(request, { planner = 'rules', plan = null, agents, routing, registry, policy, invoke = null, cwd }) {
22
+ const p = await makePlan(request, { planner, plan, agents, routing, registry, policy, invoke, cwd, trace: [] });
23
+ const { errors, tasks } = validatePlan(p.tasks, { agents, routing, policy });
24
+ return {
25
+ mode: 'dry-run', request, planner: p.planner, fallbackReason: p.fallbackReason, errors,
26
+ limits: policy.limits, maxParallelTasks: policy.decision_policy.max_parallel_tasks,
27
+ tasks: tasks.map(t => {
28
+ const m = resolveTaskModels(t, { routing, registry });
29
+ const agent = agents.find(a => a.meta.name === t.assignedAgent);
30
+ const access = accessOf(agent);
31
+ return {
32
+ ...t, executionMode: declaredMode(agent, t), tools: piToolsForAccess(access), access, workspace: workspaceOf(t, cwd),
33
+ models: { chain: m.chain, candidates: m.candidates.map(c => c.modelId), unbound: m.unbound, placeholder: m.placeholder },
34
+ };
35
+ }),
36
+ };
37
+ }
38
+
39
+ export function formatPlan(dry) {
40
+ const index = Object.fromEntries(dry.tasks.map((t, i) => [t.id, `Task ${i + 1}`]));
41
+ const lines = [`Plan: ${dry.tasks.length} tasks (planner: ${dry.planner}${dry.fallbackReason ? `, fallback: ${dry.fallbackReason}` : ''})`, ''];
42
+ dry.tasks.forEach((t, i) => {
43
+ lines.push(`Task ${i + 1} [${t.id}] ${t.title}`, ` capability: ${t.capability}`, ` agent: ${t.assignedAgent}`, ` mode: ${t.executionMode}`);
44
+ lines.push(` workspace: ${t.workspace?.path ?? '(cwd)'}`);
45
+ lines.push(` tools: ${(t.tools ?? []).join(', ') || '(none)'}`);
46
+ lines.push(` model: ${t.models.candidates.length ? t.models.candidates.join(' -> ') : `(unbound: ${[...t.models.placeholder, ...t.models.unbound].join(', ')})`}`);
47
+ if (t.acceptance?.length) lines.push(` acceptance: ${t.acceptance.join('; ')}`);
48
+ if (t.dependencies.length) lines.push(` depends_on: ${t.dependencies.map(d => index[d] ?? d).join(', ')}`);
49
+ lines.push('');
50
+ });
51
+ if (dry.errors.length) lines.push('Errors:', ...dry.errors.map(e => `- ${e}`));
52
+ return lines.join('\n').trimEnd();
53
+ }
54
+
55
+ function runStatusOf(status, escalations, tasks) {
56
+ if (escalations.length || tasks.some(t => t.status === 'waiting_for_user')) return 'waiting_for_user';
57
+ if (status === 'completed') return 'completed';
58
+ return 'failed';
59
+ }
60
+
61
+ /** Dependents parked because a dependency was waiting become pending once that dependency can run again. */
62
+ function releaseRecoverable(store) {
63
+ let changed = true;
64
+ while (changed) {
65
+ changed = false;
66
+ for (const t of store.list()) {
67
+ if (t.status !== 'blocked' || !String(t.blockedReason ?? '').startsWith('dependency ')) continue;
68
+ const stopping = t.dependencies.some(id => STOP.has(store.get(id)?.status));
69
+ if (!stopping) { store.update(t.id, { status: 'pending', blockedReason: undefined }); changed = true; }
70
+ }
71
+ }
72
+ }
73
+
74
+ export async function orchestrate(options) {
75
+ const { session, resumeRunId } = options;
76
+ let ownedRun = resumeRunId ?? null;
77
+ let token = session && resumeRunId ? session.claimRun(resumeRunId) : null;
78
+ try {
79
+ return await orchestrateImpl({ ...options, onRunCreated(id) {
80
+ ownedRun = id;
81
+ token = session.claimRun(id);
82
+ } });
83
+ } finally {
84
+ if (token && ownedRun) {
85
+ try { session.releaseRun(ownedRun, token); } catch { /* preserve the original run result/error; dead owners are reclaimed */ }
86
+ }
87
+ }
88
+ }
89
+
90
+ async function orchestrateImpl({ request: requestArg, planner = 'rules', plan = null, agents, routing, registry, policy, runner, invoke = null, repoRoot = null, projectStore = createNullProjectStore(), now = () => new Date().toISOString(), session = null, resumeRunId = null, answers = [], health = null, activity = null, onRunCreated = () => {} }) {
91
+ let request = requestArg;
92
+ let activePolicy = policy;
93
+ let dp = activePolicy.decision_policy, limits = activePolicy.limits;
94
+ let runId = null, p = { planner }, round = 0, reworkCycles = 0, seq = 0;
95
+ const trace = [], autoDecisions = [], escalations = [], unresolved = [], decisionLog = [];
96
+ const limitsHit = new Set();
97
+ let store = null, planning = true, recovered = [];
98
+ const scopeKeyOf = () => repoRoot ?? (runId ? session?.getRun(runId)?.scopeKey : null) ?? 'default';
99
+ // Live-activity bridge: the tracker decides whether an event type is significant
100
+ // (persist to the run trace) or high-frequency (live snapshot only, e.g. turns).
101
+ const event = (type, data = {}) => {
102
+ const entry = { at: now(), round, type, ...data };
103
+ const persist = activity?.emit?.(type, { runId, ...data })?.persist ?? true;
104
+ if (!persist) return entry;
105
+ trace.push(entry);
106
+ if (session && runId) session.appendTrace(runId, entry);
107
+ return entry;
108
+ };
109
+ const syncActivity = (state = null) => { if (activity && runId) activity.syncTasks({ runId, tasks: store ? store.list() : [], state }); };
110
+ const persistMeta = (status = 'running') => {
111
+ if (!session || !runId) return;
112
+ session.updateRun(runId, {
113
+ status, round, reworkCycles, seq, planner: p.planner,
114
+ counters: { autoDecisions, unresolved, limitsHit: [...limitsHit], decisionLog },
115
+ });
116
+ };
117
+ const decide = d => { autoDecisions.push(d); event('auto-decision', d); persistMeta(); };
118
+ const hitLimit = (limit, detail) => { limitsHit.add(limit); event('limit', { limit, detail }); persistMeta(); };
119
+
120
+ if (resumeRunId) {
121
+ if (!session) throw new Error('orchestrate: resume requires a persistent store');
122
+ const run = session.getRun(resumeRunId);
123
+ if (!run) throw new Error(`orchestrate: run not found: ${resumeRunId}`);
124
+ if (run.status === 'cancelled') throw new Error(`orchestrate: run ${resumeRunId} is cancelled`);
125
+ runId = run.id;
126
+ request = run.request;
127
+ activePolicy = run.policySnapshot?.limits ? run.policySnapshot : policy;
128
+ dp = activePolicy.decision_policy; limits = activePolicy.limits;
129
+ p = { planner: run.planner ?? planner };
130
+ round = run.round; reworkCycles = run.reworkCycles; seq = run.seq;
131
+ for (const l of run.counters.limitsHit ?? []) limitsHit.add(l);
132
+ autoDecisions.push(...(run.counters.autoDecisions ?? []));
133
+ unresolved.push(...(run.counters.unresolved ?? []));
134
+ decisionLog.push(...(run.counters.decisionLog ?? []));
135
+ trace.push(...session.loadTrace(runId));
136
+ health?.bindRun?.(runId);
137
+ activity?.bindRun?.({ runId, repoRoot: run.repoRoot ?? repoRoot });
138
+ recovered = session.recoverStale(runId);
139
+ for (const a of answers) {
140
+ const res = session.answerDecision({ runId, decisionId: a.decisionId, answer: a.answer, scopeKey: scopeKeyOf() });
141
+ if (res.idempotent && !res.same) unresolved.push(`decision ${a.decisionId} already answered; kept the first answer`);
142
+ }
143
+ store = session.openTaskStore(runId);
144
+ releaseRecoverable(store);
145
+ planning = store.size() === 0;
146
+ for (const d of session.listDecisions(runId, 'pending')) escalations.push(toEscalation(d));
147
+ event('resume', { runId, recovered, planning, answers: answers.map(a => a.decisionId) });
148
+ }
149
+
150
+ try {
151
+ if (planning) {
152
+ if (session && !runId) {
153
+ runId = session.createRun({ request, policy, repoRoot, planner, scopeKey: repoRoot ?? 'default' });
154
+ onRunCreated(runId);
155
+ // Bind the owning client immediately so its public snapshot exists from the start.
156
+ activity?.bindRun?.({ runId, repoRoot });
157
+ }
158
+ health?.bindRun?.(runId);
159
+ store = store ?? (session ? session.openTaskStore(runId) : createTaskStore());
160
+ p = await makePlan(request, { planner, plan, agents, routing, registry, policy: activePolicy, invoke, cwd: repoRoot ?? process.cwd(), trace, health });
161
+ if (p.fallbackReason) decide({ subject: 'planner', choice: 'rules planner', reason: `model plan unusable (${p.fallbackReason}); reversible`, step: 'reversible' });
162
+ const v = validatePlan(p.tasks, { agents, routing, policy: activePolicy });
163
+ event('plan', { planner: p.planner, tasks: v.tasks.map(t => ({ id: t.id, agent: t.assignedAgent, capability: t.capability, dependencies: t.dependencies })), errors: v.errors });
164
+ if (v.errors.length) return finish('plan-invalid', v.errors);
165
+ for (const t of v.tasks) {
166
+ store.add(newTask(t));
167
+ seq = Math.max(seq, Number(/^t(\d+)$/.exec(t.id)?.[1] ?? 0));
168
+ const m = resolveTaskModels(t, { routing, registry });
169
+ event('routing', { taskId: t.id, capability: t.capability, agent: t.assignedAgent, candidates: m.candidates.map(c => c.modelId) });
170
+ }
171
+ persistMeta('running');
172
+ syncActivity('running');
173
+ await projectStore.onPlan(store.snapshot());
174
+ } else {
175
+ syncActivity('running');
176
+ await projectStore.onPlan(store.snapshot());
177
+ }
178
+
179
+ function addTask(spec, sourceId) {
180
+ if (store.size() >= limits.max_tasks) { hitLimit('max_tasks', spec.title); unresolved.push(`not added (max_tasks=${limits.max_tasks}): ${spec.title}`); return null; }
181
+ let id; do id = `t${++seq}`; while (store.has(id));
182
+ const { errors, tasks } = validatePlan([{ ...spec, id, dependencies: spec.dependencies ?? (sourceId ? [sourceId] : []) }], { agents, routing, policy: activePolicy, existingIds: store.list().map(t => t.id) });
183
+ if (errors.length) { unresolved.push(`discovered task rejected: ${errors.join('; ')}`); event('task-rejected', { spec, errors }); return null; }
184
+ store.add(newTask({ ...tasks[0], origin: sourceId ?? 'orchestrator' }));
185
+ persistMeta();
186
+ event('task-added', { taskId: id, source: sourceId, agent: tasks[0].assignedAgent });
187
+ return id;
188
+ }
189
+
190
+ function retryOrFail(t, run, reasons) {
191
+ const failureClass = classifyRun({ ...run, failureClass: run?.failureClass });
192
+ // Models actually invoked this attempt (NOT health-skipped / already-tried /
193
+ // unavailable candidates). invocationsStarted is the attempt-budget unit.
194
+ const invoked = run?.invokedModels ?? (run?.modelId ? [run.modelId] : []);
195
+ const counters = run?.counters ?? { candidatesConsidered: invoked.length, candidatesSkipped: 0, invocationsStarted: invoked.length };
196
+ const record = { attempt: t.attempts, failureClass, reasons, summary: run?.result?.summary ?? run?.error ?? '', modelId: run?.modelId ?? invoked.at(-1), counters, filesChanged: run?.worktree?.agentChanges?.map(c => c.path) ?? run?.result?.filesChanged ?? [], commandsRun: run?.result?.commandsRun ?? [], verification: run?.result?.verification ?? [] };
197
+ const attemptsLog = [...(t.attemptsLog ?? []), record];
198
+ // Accumulate the modelIds this task should NOT retry. On a protocol failure OR a
199
+ // backend/model failure (REASSIGN_CLASSES: the model/backend itself is dead) every
200
+ // invoked model is excluded so the retry advances to an untried candidate. Only on
201
+ // a recoverable failure (test/tool) does the LAST invoked model stay retryable so a
202
+ // same-model feedback retry is still possible.
203
+ const protocol = isProtocolFailure(failureClass, run);
204
+ const reassign = REASSIGN_CLASSES.has(failureClass);
205
+ const toExclude = protocol || reassign ? invoked : invoked.slice(0, -1);
206
+ const attemptedModels = [...new Set([...(t.capabilityLocalTriedModels ?? t.attemptedModels ?? []), ...toExclude].filter(Boolean))];
207
+ // TASK-GLOBAL failed models: a model that hit a protocol-quality failure
208
+ // (malformed / empty / no-progress turn-limit) is skipped for the WHOLE task,
209
+ // even after a capability escalation re-resolves the candidate list. The main
210
+ // loop already accumulated per-invocation protocol failures onto the task; if
211
+ // this attempt's overall failure is itself a global failure, add its models too.
212
+ const taskGlobalFailedModels = [...new Set([...(run?.taskGlobalFailedModels ?? t.taskGlobalFailedModels ?? [])].filter(Boolean))];
213
+ // Attempt budget counts ACTUAL invocations only — never health skips,
214
+ // unavailable candidates, already-tried skips, or enumeration. The counter was
215
+ // already incremented in the main loop for this result; reuse the fresh value.
216
+ const totalModelAttempts = run?.totalModelAttempts ?? (t.totalModelAttempts ?? 0) + counters.invocationsStarted;
217
+ const budgetLeft = limits.max_total_attempts_per_task == null || totalModelAttempts < limits.max_total_attempts_per_task;
218
+ // How many candidates remain untried at the CURRENT capability (excluding both
219
+ // capability-local tried AND task-global-failed models).
220
+ const untriedAtCapability = () => {
221
+ try { return resolveTaskModels(t, { routing, registry }).candidates.filter(c => !attemptedModels.includes(c.modelId) && !taskGlobalFailedModels.includes(c.modelId)).length; }
222
+ catch { return 0; }
223
+ };
224
+ // Escalate capability when (a) a hard backend/model failure, or (b) a protocol
225
+ // failure with no untried candidate left at this capability. The next attempt
226
+ // re-resolves the NEW capability's candidate list from live routing.
227
+ const escalateNow = dp.reassign_on_failure && (reassign || (protocol && untriedAtCapability() === 0));
228
+ const next = escalateNow ? nextLadderCapability(routing, t.capability) : null;
229
+ // Nothing left to invoke at this capability and no ladder step: fail now instead
230
+ // of spending rounds on attempts that would invoke nothing.
231
+ const nothingLeft = !next && (protocol || reassign) && invoked.length > 0 && untriedAtCapability() === 0;
232
+ if (nothingLeft) {
233
+ store.update(t.id, { status: 'failed', blockedReason: `no untried model candidate left on ${t.capability}: ${reasons.join('; ')}`, attemptsLog, failureClass, attemptedModels, taskGlobalFailedModels, totalModelAttempts });
234
+ event('task-failed', { taskId: t.id, reasons, failureClass, totalModelAttempts, exhaustedCandidates: true });
235
+ return;
236
+ }
237
+ if ((t.attempts > limits.max_retries || !budgetLeft) && !next) {
238
+ const why = !budgetLeft ? `model attempt budget exhausted (${totalModelAttempts}/${limits.max_total_attempts_per_task})` : reasons.join('; ');
239
+ store.update(t.id, { status: 'failed', blockedReason: why, attemptsLog, failureClass, attemptedModels, taskGlobalFailedModels, totalModelAttempts });
240
+ event('task-failed', { taskId: t.id, reasons, failureClass, totalModelAttempts });
241
+ return;
242
+ }
243
+ const patch = { status: 'pending', feedback: reasons, attemptsLog, failureClass, totalModelAttempts, taskGlobalFailedModels };
244
+ const was = t.capability;
245
+ if (next) {
246
+ // Capability escalation -> re-resolve the new capability's candidates and
247
+ // reset the CAPABILITY-LOCAL tried list. taskGlobalFailedModels is NOT reset:
248
+ // a model that protocol-failed stays skipped across capabilities.
249
+ patch.capability = next;
250
+ patch.attemptedModels = [];
251
+ patch.capabilityLocalTriedModels = [];
252
+ } else {
253
+ patch.attemptedModels = attemptedModels;
254
+ patch.capabilityLocalTriedModels = attemptedModels;
255
+ }
256
+ store.update(t.id, patch);
257
+ event('retry', { taskId: t.id, attempt: t.attempts, failureClass, from: was, to: next ?? was, modelId: record.modelId, protocol, invoked, counters, taskGlobalFailedModels, reasons });
258
+ if (next) event('fallback', { taskId: t.id, from: was, to: next });
259
+ // Report the concrete model progression: which model failed (capability +
260
+ // modelId + failureClass) and what action follows. This makes strong-code
261
+ // escalations visible in the user-facing report, not just the internal trace.
262
+ const failedModel = record.modelId ? `${record.modelId} on ${was}` : was;
263
+ const action = next ? `escalate to ${next}` : protocol || reassign ? `next untried candidate on ${was}` : `retry ${was} with feedback`;
264
+ const choice = `${failedModel} → ${failureClass} → ${action}`;
265
+ decide({ taskId: t.id, subject: `${t.id} failed attempt ${t.attempts}`, capability: was, modelId: record.modelId, failureClass, action, choice, reason: `${failureClass}: ${reasons[0]}; attempt ${t.attempts}`, step: 'policy' });
266
+ }
267
+
268
+ function gate(t, requests) {
269
+ const made = [], waitFor = [], drafts = [];
270
+ let escalate = false;
271
+ for (const d of requests) {
272
+ const memory = session ? session.lookupMemory({ key: decisionKey(d), scopeKey: scopeKeyOf() }) : [];
273
+ const g = evaluateDecision(d, { policy: activePolicy, decisionLog, memory });
274
+ const log = { ...g, taskId: t.id };
275
+ if (g.step === 'memory') event('memory-lookup', { taskId: t.id, key: g.key, optionId: g.optionId, memoryId: g.memoryId, scope: g.scope });
276
+ if (g.action === 'experiment') {
277
+ const agent = g.experiment.agent ?? agentForCapability(agents, 'strong-code')?.meta.name ?? t.assignedAgent;
278
+ const id = addTask({ title: `Experiment: ${d.question}`, goal: g.experiment.goal, agent, kind: 'experiment', dependencies: [],
279
+ acceptance: ['each option was tried or assessed with concrete evidence', 'one option is recommended with rationale'] }, null);
280
+ if (id) { waitFor.push(id); decisionLog.push(log); decide({ taskId: t.id, subject: d.question, choice: `run experiment ${id}`, reason: g.reason, step: g.step }); continue; }
281
+ log.action = 'escalate'; log.reason = 'experiment needed but no task budget left';
282
+ }
283
+ decisionLog.push(log);
284
+ if (log.action === 'decide') {
285
+ const o = d.options.find(x => x.id === g.optionId);
286
+ const choice = o.summary ? `${o.id}: ${o.summary}` : o.id;
287
+ made.push({ question: d.question, choice, reason: `${g.step}: ${g.reason}` });
288
+ decide({ taskId: t.id, subject: d.question, choice, reason: g.reason, step: g.step });
289
+ } else {
290
+ escalate = true;
291
+ drafts.push({
292
+ taskId: t.id, question: d.question, options: d.options ?? [], reason: log.reason,
293
+ escalationType: (g.flags ?? [])[0] ?? g.step, flags: g.flags ?? [], key: g.key, recommended: d.recommended,
294
+ });
295
+ }
296
+ }
297
+ const decisions = [...t.decisions, ...made];
298
+ if (escalate) {
299
+ const patch = { status: session ? 'waiting_for_user' : 'blocked', decisions, dependencies: [...t.dependencies, ...waitFor], blockedReason: 'waiting for user decision' };
300
+ let ids = [];
301
+ if (session) ids = session.persistWaiting(runId, { ...store.get(t.id), ...patch, updatedAt: now() }, drafts);
302
+ store.update(t.id, patch);
303
+ drafts.forEach((draft, i) => {
304
+ const e = { id: ids[i], taskId: t.id, question: draft.question, reason: draft.reason, flags: draft.flags, options: draft.options.map(o => ({ id: o.id, summary: o.summary })), recommended: draft.recommended };
305
+ escalations.push(e); event('escalation', e);
306
+ });
307
+ return store.get(t.id);
308
+ }
309
+ if (t.attempts > limits.max_retries) return store.update(t.id, { status: 'failed', decisions, blockedReason: 'retry limit reached while resolving decisions' });
310
+ store.update(t.id, { status: 'pending', decisions, dependencies: [...t.dependencies, ...waitFor] });
311
+ }
312
+
313
+ function rework(t, issues) {
314
+ const list = issues.map(i => i.summary).join('; ');
315
+ if (reworkCycles >= limits.max_rework_cycles) { unresolved.push(`${t.id} review: blocking issues remain after ${reworkCycles} rework cycle(s): ${list}`); persistMeta(); return; }
316
+ reworkCycles++;
317
+ const impl = store.list().filter(x => x.kind === 'implement').at(-1);
318
+ const fix = addTask({ title: `Rework after review ${t.id}`, goal: `Address these review findings: ${list}${impl ? `\nOriginal goal: ${impl.goal}` : ''}`, agent: impl?.assignedAgent ?? agentForCapability(agents, 'strong-code')?.meta.name, kind: 'implement',
319
+ acceptance: ['every listed finding is fixed or explicitly rebutted with evidence', 'the relevant tests were run and pass, or the failure is reported'] }, t.id);
320
+ if (!fix) return;
321
+ addTask({ title: `Re-review after ${fix}`, goal: t.goal, agent: t.assignedAgent, kind: 'review', acceptance: t.acceptance, dependencies: [fix] }, null);
322
+ decide({ taskId: t.id, subject: `review ${t.id} found blocking issues`, choice: `send back to ${store.get(fix).assignedAgent} (${fix}) and re-review`, reason: `rework cycle ${reworkCycles}/${limits.max_rework_cycles}`, step: 'policy' });
323
+ }
324
+
325
+ const depResults = t => t.dependencies.map(id => store.get(id)).map(d => ({ id: d.id, agent: d.assignedAgent, title: d.title, ...(d.result ?? {}) }));
326
+ // Paths changed by ANY agent of this run so far (retry / rework must not treat them
327
+ // as pre-existing dirty changes) and every answered decision in the run (a rework
328
+ // task inherits the dirty-gate answer given for the original implementation).
329
+ const ownedPaths = () => [...new Set(store.list().flatMap(x => x.ownedPaths ?? []))];
330
+ const runDecisions = () => store.list().flatMap(x => x.decisions ?? []);
331
+
332
+ while (true) {
333
+ const ready = runnableTasks(store);
334
+ if (!ready.length) break;
335
+ if (round >= limits.max_rounds) { hitLimit('max_rounds', `${ready.length} runnable task(s) left`); break; }
336
+ round++;
337
+ const batch = ready.slice(0, dp.max_parallel_tasks);
338
+ for (const t of batch) store.update(t.id, { status: 'running', attempts: t.attempts + 1 });
339
+ persistMeta('running');
340
+ syncActivity('running');
341
+ event('round', { running: batch.map(t => t.id), deferred: ready.slice(batch.length).map(t => t.id) });
342
+ const owned = ownedPaths(), decided = runDecisions();
343
+ const runs = await Promise.all(batch.map(t => Promise.resolve().then(() => runner.run(t, { dependencyResults: depResults(t), runId, ownedPaths: owned, runDecisions: decided, onEvent: (type, data) => event(type, { runId, ...data }) })).catch(e => ({ ok: false, error: e.message }))));
344
+ for (const [i, t] of batch.entries()) {
345
+ const run = runs[i];
346
+ const ev = evaluateResult(t, run, { repoRoot });
347
+ if (run.child) event('child', run.child);
348
+ event('result', { taskId: t.id, agent: t.assignedAgent, capability: t.capability, attempt: t.attempts, executor: run.executor, modelId: run.modelId, verdict: ev.verdict, reasons: ev.reasons, failureClass: classifyRun(run), steps: run.steps, counters: run.counters, invokedModels: run.invokedModels, raw: run.raw });
349
+ // Accumulate real invocations on EVERY result (success or failure) so the
350
+ // budget reflects actual model calls, not just failed attempts.
351
+ const invokedThisRound = run?.counters?.invocationsStarted ?? (run?.invokedModels?.length ?? (run?.modelId ? 1 : 0));
352
+ const totalModelAttempts = (t.totalModelAttempts ?? 0) + invokedThisRound;
353
+ run.totalModelAttempts = totalModelAttempts; // hand the fresh count to retryOrFail
354
+ // Mark task-global failed models from EVERY invocation that hit a protocol
355
+ // failure — even when the task ultimately recovers on a later candidate.
356
+ // This is what stops a malformed/empty/no-progress-timeout model from being
357
+ // re-invoked after a capability escalation.
358
+ const globalFailedNow = (run?.steps ?? []).filter(s => s.protocolFailure && shouldMarkTaskGlobalFailure(s.protocolFailure, {
359
+ toolCalls: s.child?.toolCalls ?? s.telemetry?.toolCalls,
360
+ turnLimit: ['no-progress-turn-limit', 'absolute-turn-limit'].includes(s.child?.stopReason) || /turn limit/i.test(s.reason ?? ''),
361
+ structuredProgress: s.child?.structuredProgress ?? (s.protocolFailure === 'NO_PROGRESS_TIMEOUT' ? false : undefined),
362
+ hasFinalOutput: s.child?.hasFinalOutput ?? (s.protocolFailure === 'NO_PROGRESS_TIMEOUT' ? false : undefined),
363
+ })).map(s => s.modelId).filter(Boolean);
364
+ const taskGlobalFailedModels = [...new Set([...(t.taskGlobalFailedModels ?? []), ...globalFailedNow])];
365
+ run.taskGlobalFailedModels = taskGlobalFailedModels;
366
+ for (const step of run.steps ?? []) {
367
+ if (step.skipped && step.reason === 'task-global-failed') {
368
+ decide({ taskId: t.id, subject: `${t.id} candidate skip`, capability: step.capability, modelId: step.modelId, failureClass: 'task-global-failed', action: 'skip', choice: `${step.modelId} on ${step.capability} → task-global-failed skip`, reason: 'protocol failure on an earlier capability', step: 'policy' });
369
+ } else if (!step.ok && !step.skipped && step.modelId && step.failureClass && step.capability === 'strong-code') {
370
+ decide({ taskId: t.id, subject: `${t.id} candidate failed`, capability: step.capability, modelId: step.modelId, failureClass: step.failureClass, action: 'next candidate', choice: `${step.modelId} on ${step.capability} → ${step.failureClass} → next candidate`, reason: step.reason, step: 'policy' });
371
+ }
372
+ }
373
+ const changedNow = (run?.worktree?.agentChanges ?? []).map(c => c.path);
374
+ const ownedNow = [...new Set([...(t.ownedPaths ?? []), ...changedNow])];
375
+ store.update(t.id, { result: run.result ?? { status: 'failed', summary: run.error }, modelId: run.modelId, totalModelAttempts, taskGlobalFailedModels, ownedPaths: ownedNow });
376
+ if (run.gate) event('gate', { taskId: t.id, gateType: run.gate.type, action: run.gate.action, key: run.gate.key, paths: run.gate.paths });
377
+ if (run.abort) {
378
+ // Runner-level terminal stop (e.g. user answered "abort" to the dirty-worktree
379
+ // gate): the task is blocked for good; never retried, never re-asked.
380
+ store.update(t.id, { status: 'blocked', blockedReason: run.error ?? 'aborted by user decision' });
381
+ event('task-blocked', { taskId: t.id, reason: run.error, gate: run.gate });
382
+ await projectStore.onTaskUpdate(structuredClone(store.get(t.id)));
383
+ continue;
384
+ }
385
+ if (ev.verdict === 'success') {
386
+ if (t.attempts > 1 && run.modelId && t.capability === 'strong-code') {
387
+ decide({ taskId: t.id, subject: `${t.id} escalated invocation`, capability: t.capability, modelId: run.modelId, failureClass: null, action: 'completed', choice: `${run.modelId} on ${t.capability} → completed`, reason: 'escalation recovered the task', step: 'policy' });
388
+ }
389
+ store.update(t.id, { status: 'completed', feedback: [] });
390
+ for (const spec of ev.newTasks) addTask(spec, t.id);
391
+ if (ev.blockingIssues.length) rework(t, ev.blockingIssues);
392
+ } else if (ev.verdict === 'blocked') {
393
+ gate(t, ev.decisions);
394
+ } else {
395
+ retryOrFail(t, run, ev.reasons);
396
+ }
397
+ await projectStore.onTaskUpdate(structuredClone(store.get(t.id)));
398
+ syncActivity();
399
+ }
400
+ }
401
+
402
+ for (let stranded = strandedTasks(store); stranded.length; stranded = strandedTasks(store)) {
403
+ for (const t of stranded) {
404
+ const dep = t.dependencies.map(d => store.get(d)).find(d => d && STOP.has(d.status));
405
+ store.update(t.id, { status: 'blocked', blockedReason: `dependency ${dep.id} is ${dep.status}` });
406
+ }
407
+ }
408
+ } catch (e) {
409
+ // Exception-safe termination: never leave the run (or its in-flight tasks) as
410
+ // `running` in the store. If the store itself is unwritable we report that
411
+ // limitation instead of masking the original error.
412
+ const persisted = terminateOnError(e);
413
+ const err = new Error(`orchestrate: ${e.message}${persisted.ok ? '' : ` (run state could not be persisted: ${persisted.error})`}`);
414
+ err.cause = e; err.runId = runId; err.persisted = persisted.ok;
415
+ throw err;
416
+ }
417
+ const open = store.list().filter(t => t.status !== 'completed');
418
+ const status = escalations.length ? 'needs-user' : open.length || unresolved.length ? 'incomplete' : 'completed';
419
+ return finish(status, []);
420
+
421
+ function terminateOnError(e) {
422
+ if (!session || !runId) return { ok: true, skipped: true };
423
+ try {
424
+ session.transaction(() => {
425
+ for (const t of session.loadTasks(runId)) {
426
+ if (t.status !== 'running') continue;
427
+ session.saveTask(runId, { ...t, status: 'failed', blockedReason: `run aborted by error: ${String(e.message).slice(0, 300)}` });
428
+ }
429
+ session.appendTrace(runId, { at: now(), round, type: 'error', message: String(e.message).slice(0, 2000), stack: String(e.stack ?? '').slice(0, 4000) });
430
+ session.updateRun(runId, { status: 'failed', round, reworkCycles, seq, planner: p.planner, counters: { autoDecisions, unresolved: [...unresolved, `run aborted: ${e.message}`], limitsHit: [...limitsHit], decisionLog } });
431
+ });
432
+ syncActivity('failed');
433
+ activity?.finishRun?.(runId);
434
+ return { ok: true };
435
+ } catch (e2) {
436
+ return { ok: false, error: e2.message };
437
+ }
438
+ }
439
+
440
+ async function finish(status, errors) {
441
+ const tasks = store ? store.snapshot() : [];
442
+ const runStatus = session ? runStatusOf(status, escalations, tasks) : undefined;
443
+ if (session && runId) persistMeta(runStatus);
444
+ syncActivity(runStatus ?? status);
445
+ if (['completed', 'failed', 'cancelled'].includes(runStatus ?? status)) activity?.finishRun?.(runId);
446
+ const result = { status, runStatus, runId, request, planner: p.planner, rounds: round, reworkCycles, errors, tasks, autoDecisions, escalations, unresolved, limitsHit: [...limitsHit], trace, recovered };
447
+ await projectStore.onFinal(result);
448
+ return result;
449
+ }
450
+ }
451
+
452
+ export function toEscalation(d) {
453
+ return { id: d.id, taskId: d.taskId, question: d.question, reason: d.reason, flags: d.flags ?? [], options: (d.options ?? []).map(o => ({ id: o.id, summary: o.summary })), recommended: d.recommended };
454
+ }
455
+
456
+ const first = (s, n = 160) => { const line = String(s ?? '').split(/\r?\n/).find(x => x.trim()) ?? ''; return line.length > n ? `${line.slice(0, n)}…` : line; };
457
+
458
+ /** Human-facing summary. Detailed agent exchanges stay in result.trace. */
459
+ export function formatReport(result) {
460
+ const done = result.tasks.filter(t => t.status === 'completed');
461
+ const open = result.tasks.filter(t => t.status !== 'completed');
462
+ const head = result.runId ? `状態: ${result.status} (run ${result.runId}, ${result.runStatus})` : `状態: ${result.status}`;
463
+ const lines = [`${head} (tasks ${done.length}/${result.tasks.length}, rounds ${result.rounds})`, '', '完了:'];
464
+ lines.push(...(done.length ? done.map(t => `- [${t.id}] ${t.title}: ${first(t.result?.summary)}`) : ['- なし']));
465
+ lines.push('', '自動判断:');
466
+ lines.push(...(result.autoDecisions.length ? result.autoDecisions.map(d => `- ${d.subject} → ${d.choice}(${d.reason})`) : ['- なし']));
467
+ lines.push('', '未解決:');
468
+ const openLines = [
469
+ ...result.errors.map(e => `- ${e}`),
470
+ ...open.map(t => `- [${t.id}] ${t.title}: ${t.status}${t.blockedReason ? ` — ${first(t.blockedReason, 240)}` : ''}`),
471
+ ...result.unresolved.map(u => `- ${u}`),
472
+ ...result.limitsHit.map(l => `- limit reached: ${l}`),
473
+ ];
474
+ lines.push(...(openLines.length ? openLines : ['- なし']));
475
+ lines.push('', 'ユーザー判断が必要:');
476
+ lines.push(...(result.escalations.length ? result.escalations.map(e => `- [${e.taskId}]${e.id ? ` ${e.id}` : ''} ${e.question} — ${e.reason}${e.options.length ? ` (options: ${e.options.map(o => o.summary ? `${o.id}=${o.summary}` : o.id).join(' / ')})` : ''}`) : ['- なし']));
477
+ return lines.join('\n');
478
+ }
479
+
480
+ export function formatRunList(rows) {
481
+ if (!rows.length) return 'runs: none';
482
+ return rows.map(r => `${r.id} ${r.status} ${r.completed}/${r.total} decisions:${r.pendingDecisions} ${r.updatedAt} ${String(r.request).replace(/\s+/g, ' ').slice(0, 72)}`).join('\n');
483
+ }