@worca/app 1.0.0 → 1.2.0-rc.1
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.
- package/README.md +30 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +386 -56
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +199 -23
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +545 -0
- package/src/core/ask/follow.mjs +113 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +57 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +286 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +156 -0
- package/src/core/ask/store.mjs +438 -0
- package/src/core/ask/tool-deps.mjs +87 -0
- package/src/core/ask/tools.mjs +879 -0
- package/src/core/ask/turn.mjs +462 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +28 -7
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +541 -62
- package/src/core/config.mjs +310 -44
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +773 -53
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1072 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +180 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +994 -4097
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +80 -17
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3934 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +184 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4240 -1682
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +317 -0
- package/ui/public/ask-panel.mjs +2129 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +311 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1487 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1894 -104
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
// One Ask Worca turn: spawn `claude -p` through the P1 sandbox recipe, feed
|
|
2
|
+
// every event to the P1 reducer, persist the assistant message, and emit bare
|
|
3
|
+
// ask-* frames through deps.onFrame (the SERVER stamps {threadId, messageId,
|
|
4
|
+
// seq} — spec §17 contract). run() NEVER throws; stop() aborts. One instance
|
|
5
|
+
// owns one turn INCLUDING the §6.2.7 resume retry (fresh reducer per attempt,
|
|
6
|
+
// one AbortController + one 30-minute wall clock spanning both attempts).
|
|
7
|
+
// Shape: agent-gen.mjs (EventEmitter, terminal latch, finally cleanup).
|
|
8
|
+
// Binding rules enforced here: R-A (settle-before-finish, persist card/notice
|
|
9
|
+
// mid-turn), R-C (rejection classification, abort branch FIRST — the runner
|
|
10
|
+
// throws a synchronous AbortError before any init when pre-aborted), R-F
|
|
11
|
+
// (turn.mock rides EVERY attempt), R-G (spawn wiring: scratch dir, RAW home
|
|
12
|
+
// base, per-message mcp json deleted in finally), R-D + B-1 (title call:
|
|
13
|
+
// hardened options + permissionMode 'dontAsk', no signal).
|
|
14
|
+
import { EventEmitter } from 'node:events';
|
|
15
|
+
import { join, dirname, resolve as pathResolve } from 'node:path';
|
|
16
|
+
import { mkdir, writeFile, unlink } from 'node:fs/promises';
|
|
17
|
+
|
|
18
|
+
import { runClaude } from '../claude-runner.mjs';
|
|
19
|
+
import { resolveModelEnv, resolveModelCost, estimateCost, liveCostRates as defaultLiveCostRates } from '../config.mjs';
|
|
20
|
+
import { worcaHome } from '../projects.mjs';
|
|
21
|
+
import { generateTitle } from '../title.mjs';
|
|
22
|
+
import { createTurnReducer } from './events.mjs';
|
|
23
|
+
import { buildAskSpawnOptions, buildMcpConfig, ASK_MCP_SERVER_PATH } from './spawn.mjs';
|
|
24
|
+
import { validateProposal } from './proposal.mjs';
|
|
25
|
+
import { askLimits, ASK_LIMITS } from './limits.mjs';
|
|
26
|
+
import {
|
|
27
|
+
newAskId, finishMessage, setMessageBlocks, addThreadTotals, updateThread, setThreadTitle,
|
|
28
|
+
} from './store.mjs';
|
|
29
|
+
import { recordAskCostDelta } from '../cost-budget.mjs';
|
|
30
|
+
import { setPendingCardComments } from '../diff-comments.mjs';
|
|
31
|
+
|
|
32
|
+
export function createAskTurn(opts) { return new AskTurn(opts); }
|
|
33
|
+
|
|
34
|
+
const TERMINAL = new Set(['done', 'stopped', 'error']);
|
|
35
|
+
|
|
36
|
+
class AskTurn extends EventEmitter {
|
|
37
|
+
constructor({
|
|
38
|
+
threadId, assistantMessageId, userMessageId,
|
|
39
|
+
prompt, systemPrompt, restoredPrompt = '',
|
|
40
|
+
model, effort, resumeSessionId = null,
|
|
41
|
+
firstTurn = false, firstText = '', deterministicTitle = null,
|
|
42
|
+
mock = null, attachmentNames = {},
|
|
43
|
+
pinnedScope = null,
|
|
44
|
+
deps = {},
|
|
45
|
+
} = {}) {
|
|
46
|
+
super();
|
|
47
|
+
this.threadId = threadId;
|
|
48
|
+
this.assistantMessageId = assistantMessageId;
|
|
49
|
+
this.userMessageId = userMessageId;
|
|
50
|
+
this.prompt = prompt;
|
|
51
|
+
this.systemPrompt = systemPrompt;
|
|
52
|
+
this.restoredPrompt = restoredPrompt;
|
|
53
|
+
this.model = model;
|
|
54
|
+
this.effort = effort;
|
|
55
|
+
this.resumeSessionId = resumeSessionId || null;
|
|
56
|
+
this.firstTurn = !!firstTurn;
|
|
57
|
+
this.firstText = firstText;
|
|
58
|
+
this.deterministicTitle = deterministicTitle ?? null;
|
|
59
|
+
this.mock = mock || null;
|
|
60
|
+
this.attachmentNames = attachmentNames || {};
|
|
61
|
+
// #397: {projectKey}|{workspaceId}|null — the user-pinned scope at POST time.
|
|
62
|
+
this.pinnedScope = pinnedScope && typeof pinnedScope === 'object' ? pinnedScope : null;
|
|
63
|
+
this.deps = {
|
|
64
|
+
runClaudeImpl: deps.runClaudeImpl ?? runClaude,
|
|
65
|
+
store: {
|
|
66
|
+
finishMessage, setMessageBlocks, addThreadTotals, updateThread, setThreadTitle,
|
|
67
|
+
...(deps.store || {}),
|
|
68
|
+
},
|
|
69
|
+
validateProposal: deps.validateProposal ?? validateProposal,
|
|
70
|
+
generateTitle: deps.generateTitle ?? generateTitle,
|
|
71
|
+
askLimits: deps.askLimits ?? askLimits,
|
|
72
|
+
limits: deps.limits ?? ASK_LIMITS,
|
|
73
|
+
resolveModelEnv: deps.resolveModelEnv ?? resolveModelEnv,
|
|
74
|
+
resolveModelCost: deps.resolveModelCost ?? resolveModelCost,
|
|
75
|
+
worcaHome: deps.worcaHome ?? worcaHome,
|
|
76
|
+
buildMcpConfig: deps.buildMcpConfig ?? buildMcpConfig,
|
|
77
|
+
serverPath: deps.serverPath ?? ASK_MCP_SERVER_PATH,
|
|
78
|
+
newAskId: deps.newAskId ?? newAskId,
|
|
79
|
+
setPendingCardComments: deps.setPendingCardComments ?? setPendingCardComments,
|
|
80
|
+
recordAskCost: deps.recordAskCost ?? recordAskCostDelta,
|
|
81
|
+
now: deps.now ?? Date.now,
|
|
82
|
+
// Default timers unref so a 30-minute clock never holds the process open
|
|
83
|
+
// (orchestrator.mjs:2627 _backoff precedent). Tests inject both.
|
|
84
|
+
setTimeout: deps.setTimeout ?? ((fn, ms) => { const t = setTimeout(fn, ms); t.unref?.(); return t; }),
|
|
85
|
+
clearTimeout: deps.clearTimeout ?? ((t) => clearTimeout(t)),
|
|
86
|
+
fs: deps.fs ?? { mkdir, writeFile, unlink },
|
|
87
|
+
onFrame: deps.onFrame ?? (() => {}),
|
|
88
|
+
onOutOfTurn: deps.onOutOfTurn ?? (() => {}),
|
|
89
|
+
onCommentMutation: deps.onCommentMutation ?? (() => {}),
|
|
90
|
+
onWorktreeMutation: deps.onWorktreeMutation ?? (() => {}),
|
|
91
|
+
// DISPLAY-ONLY rates for the footer's live "≈" estimate (config.mjs
|
|
92
|
+
// liveCostRates: override → list price → null). Injectable so tests pin
|
|
93
|
+
// the frame arithmetic without the catalog.
|
|
94
|
+
liveCostRates: deps.liveCostRates ?? defaultLiveCostRates,
|
|
95
|
+
};
|
|
96
|
+
this.abort = new AbortController();
|
|
97
|
+
this.status = 'created';
|
|
98
|
+
this.timedOut = false;
|
|
99
|
+
this.stopping = false;
|
|
100
|
+
this.reducer = null;
|
|
101
|
+
this.sessionId = this.resumeSessionId;
|
|
102
|
+
this.scratchDir = null;
|
|
103
|
+
this.titlePromise = Promise.resolve();
|
|
104
|
+
this._titleKicked = false;
|
|
105
|
+
this._completed = false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
stop() {
|
|
109
|
+
if (TERMINAL.has(this.status)) return;
|
|
110
|
+
this.stopping = true;
|
|
111
|
+
try { this.abort.abort(); } catch { /* ignore */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_frame(frame) {
|
|
115
|
+
try { this.deps.onFrame(frame); } catch { /* a broken sink must not break the turn */ }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_emit(event, payload) {
|
|
119
|
+
// EventEmitter special-cases 'error': emitting it with ZERO listeners throws
|
|
120
|
+
// ERR_UNHANDLED_ERROR, and a listener that throws escapes too — either would
|
|
121
|
+
// break the "run() NEVER throws" contract P3 builds on. Both terminal emits
|
|
122
|
+
// go through here; same swallow posture as _frame.
|
|
123
|
+
if (event === 'error' && this.listenerCount('error') === 0) return;
|
|
124
|
+
try { this.emit(event, payload); } catch { /* a broken listener must not break the turn */ }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_persistBlocks() {
|
|
128
|
+
// R-A: the card (and every mid-turn notice) must survive a server restart
|
|
129
|
+
// and be visible to findCard/updateCardBlock while the turn streams.
|
|
130
|
+
try { this.deps.store.setMessageBlocks(this.assistantMessageId, this.reducer.snapshot().blocks); }
|
|
131
|
+
catch { /* thread may be gone — the terminal write is equally guarded */ }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async _onProposal(input) {
|
|
135
|
+
const d = this.deps;
|
|
136
|
+
const cardId = d.newAskId('card');
|
|
137
|
+
const raw = input && typeof input === 'object' ? input : {};
|
|
138
|
+
// #397: a proposal that names NO target falls back to the user-pinned scope.
|
|
139
|
+
// Mirrors the MCP child's own defaulting, so this authoritative re-validation
|
|
140
|
+
// builds the same card the model was shown.
|
|
141
|
+
const pin = this.pinnedScope;
|
|
142
|
+
const hasTarget = (typeof raw.projectKey === 'string' && raw.projectKey.trim())
|
|
143
|
+
|| (typeof raw.workspaceId === 'string' && raw.workspaceId.trim());
|
|
144
|
+
const inp = pin && !hasTarget ? { ...raw, ...pin } : raw;
|
|
145
|
+
try {
|
|
146
|
+
const r = await d.validateProposal(inp, { cardId });
|
|
147
|
+
if (r && r.ok) {
|
|
148
|
+
// #397 guardrail: a proposal targeting a DIFFERENT project/workspace than
|
|
149
|
+
// the pinned one is accepted but flagged — the card renders the mismatch
|
|
150
|
+
// instead of silently absorbing it.
|
|
151
|
+
const scopeMismatch = !!pin && ((pin.projectKey && r.card.projectKey !== pin.projectKey)
|
|
152
|
+
|| (pin.workspaceId && r.card.workspaceId !== pin.workspaceId));
|
|
153
|
+
this.reducer.addBlock({ kind: 'card', id: cardId, state: 'proposed', card: r.card, ...(scopeMismatch ? { scopeMismatch: true } : {}) });
|
|
154
|
+
// commentIds are propose_run INPUT only: they never enter the card block (its
|
|
155
|
+
// key set is pinned in test/ask-proposal.test.mjs) nor CARD_PATCH_KEYS. Parked
|
|
156
|
+
// against the card id until the user starts the run; unknown ids are dropped,
|
|
157
|
+
// because the model may cite a comment the user has since deleted and that
|
|
158
|
+
// must not sink an otherwise valid proposal.
|
|
159
|
+
try { d.setPendingCardComments(cardId, input?.commentIds); }
|
|
160
|
+
catch { /* comment metadata is never worth failing a proposal for */ }
|
|
161
|
+
} else {
|
|
162
|
+
const errors = (r && Array.isArray(r.errors) && r.errors.length) ? r.errors : ['invalid proposal'];
|
|
163
|
+
this.reducer.addBlock({ kind: 'notice', text: `Proposal rejected: ${errors.join('; ')}` });
|
|
164
|
+
}
|
|
165
|
+
} catch (err) {
|
|
166
|
+
this.reducer.addBlock({ kind: 'notice', text: `Proposal rejected: ${err?.message || err}` });
|
|
167
|
+
}
|
|
168
|
+
this._persistBlocks();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
_makeReducer() {
|
|
172
|
+
const d = this.deps;
|
|
173
|
+
// One settings read per attempt, never per frame. null → the frames carry
|
|
174
|
+
// estimatedCostUsd:null and the footer keeps today's behaviour.
|
|
175
|
+
let liveRates = null;
|
|
176
|
+
try { liveRates = d.liveCostRates(this.model) ?? null; } catch { liveRates = null; }
|
|
177
|
+
this.reducer = createTurnReducer({
|
|
178
|
+
onFrame: (f) => this._frame(f),
|
|
179
|
+
now: d.now,
|
|
180
|
+
setTimeout: d.setTimeout,
|
|
181
|
+
clearTimeout: d.clearTimeout,
|
|
182
|
+
attachmentNames: this.attachmentNames,
|
|
183
|
+
// Ask spend feeds the SAME windowed budget as pipeline spend
|
|
184
|
+
// (cost-budget.mjs combinedWindowedSpendUsd), so an on-prem model the CLI
|
|
185
|
+
// prices by name inflates it from here too — re-price the turn exactly as
|
|
186
|
+
// the orchestrator's result intake does. Trusts the CLI when the model
|
|
187
|
+
// carries no override, which is the default.
|
|
188
|
+
resolveCost: (cliCostUsd, usage) => d.resolveModelCost(this.model, cliCostUsd, usage),
|
|
189
|
+
limits: d.limits,
|
|
190
|
+
onProposal: ({ input }) => this._onProposal(input),
|
|
191
|
+
// The MCP child cannot broadcast; the parent turns its comment writes into
|
|
192
|
+
// the same poke the REST routes emit.
|
|
193
|
+
onCommentMutation: (e) => { try { this.deps.onCommentMutation(e); } catch { /* a broken sink never breaks the turn */ } },
|
|
194
|
+
// Same shape for worktrees: open/remove/navigate in the child → the server
|
|
195
|
+
// broadcasts the thread's worktree envelope (ui/server.mjs emitAskWorktrees).
|
|
196
|
+
onWorktreeMutation: (e) => { try { this.deps.onWorktreeMutation(e); } catch { /* a broken sink never breaks the turn */ } },
|
|
197
|
+
// DISPLAY ONLY — never a sink input: prices the running usage sum (main +
|
|
198
|
+
// sub-agent tokens) at the TURN model's rates; the "≈" in the footer owns
|
|
199
|
+
// that approximation. _complete() reads summary.costUsd, not this.
|
|
200
|
+
estimateLiveCost: liveRates ? (usage) => estimateCost(usage, liveRates) : null,
|
|
201
|
+
});
|
|
202
|
+
return this.reducer;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async _settle() {
|
|
206
|
+
// R-A verbatim: settle() has no timeout of its own — race it against the
|
|
207
|
+
// turn's abort so a hung proposal hook cannot wedge the terminal write.
|
|
208
|
+
const aborted = new Promise((res) => {
|
|
209
|
+
if (this.abort.signal.aborted) return res();
|
|
210
|
+
this.abort.signal.addEventListener('abort', () => res(), { once: true });
|
|
211
|
+
});
|
|
212
|
+
await Promise.race([this.reducer.settle(), aborted]);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The single terminal writer — called exactly once per run().
|
|
217
|
+
* kind 'done' → ask-done{status:'done'|'stopped', reason?}
|
|
218
|
+
* kind 'error' → ask-error{message, errorClass?} with message status 'error'.
|
|
219
|
+
* The §6.2.8 costUsd:null rule needs no plumbing here: the P1 reducer sets
|
|
220
|
+
* lastResult and sawResult together (events.mjs currentCost() reads lastResult,
|
|
221
|
+
* set only by a `result` frame), so summary.costUsd is ALREADY null whenever no
|
|
222
|
+
* `result` arrived — source-verified. P1's ask-events tests pin only the
|
|
223
|
+
* per-frame ask-usage costUsd:null (:51); the R-C stop test in THIS file is
|
|
224
|
+
* the end-to-end pin of the summary rule.
|
|
225
|
+
*/
|
|
226
|
+
async _complete({ kind, status, reason = null, message = null, errorClass = undefined }) {
|
|
227
|
+
if (this._completed) return { status: this.status };
|
|
228
|
+
this._completed = true;
|
|
229
|
+
const d = this.deps;
|
|
230
|
+
await this._settle();
|
|
231
|
+
const summary = this.reducer.finish();
|
|
232
|
+
const finalStatus = kind === 'error' ? 'error' : status;
|
|
233
|
+
// Already AUTHORITATIVE: the reducer applied this turn's per-model cost
|
|
234
|
+
// override (the `resolveCost` hook in _makeReducer), so this one value is
|
|
235
|
+
// correct for all four sinks below — the message row, the thread totals, the
|
|
236
|
+
// budget ledger, and the ask-done frame.
|
|
237
|
+
const costUsd = summary.costUsd;
|
|
238
|
+
// Persist BEFORE broadcasting: a client re-fetch on the terminal frame must
|
|
239
|
+
// never see a still-streaming row. finishMessage gets the FULL patch (B-5).
|
|
240
|
+
try {
|
|
241
|
+
d.store.finishMessage(this.assistantMessageId, {
|
|
242
|
+
text: summary.text, blocks: summary.blocks, status: finalStatus, reason,
|
|
243
|
+
usage: summary.usage, costUsd, durationMs: summary.durationMs,
|
|
244
|
+
});
|
|
245
|
+
} catch { /* deleted thread — the frames still settle the UI */ }
|
|
246
|
+
let threadTotals = null;
|
|
247
|
+
try {
|
|
248
|
+
threadTotals = d.store.addThreadTotals(this.threadId, {
|
|
249
|
+
costUsd, usage: summary.usage, agents: summary.agents,
|
|
250
|
+
});
|
|
251
|
+
} catch { /* deleted thread */ }
|
|
252
|
+
// D10: the spend is a financial fact even when the thread was deleted
|
|
253
|
+
// mid-turn — sits OUTSIDE the store try/catches above so it is never
|
|
254
|
+
// skipped; best-effort so a DB hiccup still settles the frames. Written
|
|
255
|
+
// after finishMessage: a process death between the two loses only this
|
|
256
|
+
// row (accepted — the v20 backfill never re-runs). Runs on done, stopped
|
|
257
|
+
// AND error turns alike: a result frame means money was spent.
|
|
258
|
+
try {
|
|
259
|
+
d.recordAskCost({
|
|
260
|
+
threadId: this.threadId, messageId: this.assistantMessageId,
|
|
261
|
+
amountUsd: costUsd, // null → the writer no-ops (D2)
|
|
262
|
+
tokens: ['input', 'output', 'cacheRead', 'cacheCreation']
|
|
263
|
+
.reduce((a, k) => a + (Number(summary.usage?.[k]) || 0), 0),
|
|
264
|
+
model: this.model, tsMs: d.now(),
|
|
265
|
+
});
|
|
266
|
+
} catch { /* ledger append is best-effort */ }
|
|
267
|
+
this.status = finalStatus;
|
|
268
|
+
if (summary.reducerErrors) {
|
|
269
|
+
console.warn(`[worca-ask] turn ${this.assistantMessageId}: ${summary.reducerErrors} reducer error(s) absorbed`);
|
|
270
|
+
}
|
|
271
|
+
if (kind === 'error') {
|
|
272
|
+
this._frame({ type: 'ask-error', message: message || 'unknown error', ...(errorClass !== undefined ? { errorClass } : {}) });
|
|
273
|
+
this._emit('error', { message: message || 'unknown error' });
|
|
274
|
+
} else {
|
|
275
|
+
this._frame({
|
|
276
|
+
type: 'ask-done', text: summary.text, blocks: summary.blocks, usage: summary.usage,
|
|
277
|
+
costUsd, durationMs: summary.durationMs, model: this.model, status: finalStatus,
|
|
278
|
+
...(reason ? { reason } : {}), threadTotals,
|
|
279
|
+
});
|
|
280
|
+
this._emit('done', { status: finalStatus, reason });
|
|
281
|
+
}
|
|
282
|
+
return { status: finalStatus };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
_limitNotice(reason, limitsNow) {
|
|
286
|
+
const text = reason === 'max_budget'
|
|
287
|
+
? `Stopped: reached the $${limitsNow.maxBudgetUsd} per-turn cap (Settings → Ask Worca)`
|
|
288
|
+
: `Stopped: reached the ${limitsNow.maxTurns}-turn limit (Settings → Ask Worca)`;
|
|
289
|
+
this.reducer.addBlock({ kind: 'notice', text });
|
|
290
|
+
this._persistBlocks();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async run() {
|
|
294
|
+
if (this.status !== 'created') return { status: this.status };
|
|
295
|
+
this.status = 'running';
|
|
296
|
+
const d = this.deps;
|
|
297
|
+
this._makeReducer();
|
|
298
|
+
this._frame({
|
|
299
|
+
type: 'ask-start', userMessageId: this.userMessageId,
|
|
300
|
+
model: this.model, effort: this.effort, startedAt: new Date(d.now()).toISOString(),
|
|
301
|
+
});
|
|
302
|
+
let timer = null;
|
|
303
|
+
let mcpConfigPath = null;
|
|
304
|
+
let out;
|
|
305
|
+
try {
|
|
306
|
+
// R-G: ONE scratch dir for all threads, RAW home base (never worcaHome()
|
|
307
|
+
// itself — it already ends in /.worca-cc), per-message config json.
|
|
308
|
+
const scratchDir = join(d.worcaHome(), 'tmp', 'ask');
|
|
309
|
+
this.scratchDir = scratchDir;
|
|
310
|
+
await d.fs.mkdir(scratchDir, { recursive: true });
|
|
311
|
+
// D13 title runs CONCURRENTLY with the turn from here — the haiku call
|
|
312
|
+
// cwd's into scratchDir, so not a line earlier. Idempotent: the call after
|
|
313
|
+
// _attempts below is the backstop for a mkdir/write failure, so "fires
|
|
314
|
+
// after ANY terminal status of the first turn" stays true.
|
|
315
|
+
this._kickoffTitle();
|
|
316
|
+
const homeBase = process.env.WORCA_HOME?.trim()
|
|
317
|
+
? pathResolve(process.env.WORCA_HOME)
|
|
318
|
+
: dirname(d.worcaHome());
|
|
319
|
+
mcpConfigPath = join(scratchDir, `mcp-${this.assistantMessageId}.json`);
|
|
320
|
+
await d.fs.writeFile(
|
|
321
|
+
mcpConfigPath,
|
|
322
|
+
JSON.stringify(d.buildMcpConfig({ homeBase, threadId: this.threadId, serverPath: d.serverPath }), null, 2),
|
|
323
|
+
'utf8',
|
|
324
|
+
);
|
|
325
|
+
// One 30-minute budget for the whole turn, retry included. The timedOut
|
|
326
|
+
// flag and abort() run in ONE synchronous callback, so R-C always reads
|
|
327
|
+
// the flag set (the awaiting continuation resumes a microtask later);
|
|
328
|
+
// flag-first is kept as defensive style (plugin-shim.mjs:164 precedent).
|
|
329
|
+
timer = d.setTimeout(() => { this.timedOut = true; try { this.abort.abort(); } catch { /* ignore */ } }, d.limits.turnTimeoutMs);
|
|
330
|
+
const limitsNow = d.askLimits(); // D12: read fresh every turn
|
|
331
|
+
out = await this._attempts(limitsNow, mcpConfigPath, scratchDir);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
// Backstop for a deps failure (mkdir/write) — _attempts itself never throws.
|
|
334
|
+
out = await this._complete({ kind: 'error', message: err?.message || String(err) });
|
|
335
|
+
} finally {
|
|
336
|
+
if (timer != null) d.clearTimeout(timer);
|
|
337
|
+
if (mcpConfigPath) await d.fs.unlink(mcpConfigPath).catch(() => {});
|
|
338
|
+
}
|
|
339
|
+
this._kickoffTitle();
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async _attempts(limitsNow, mcpConfigPath, scratchDir) {
|
|
344
|
+
const d = this.deps;
|
|
345
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
346
|
+
const isRetry = attempt === 2;
|
|
347
|
+
if (isRetry) {
|
|
348
|
+
this._makeReducer(); // fresh reducer; the dead attempt's reducer is discarded unfinished
|
|
349
|
+
// Deliberate deviation from §6.2.7's ordering (which posts the notice
|
|
350
|
+
// after a successful restore): the notice is added EAGERLY so it is
|
|
351
|
+
// visible while the retry streams (R-A persistence below). If the retry
|
|
352
|
+
// then fails, the notice stays above the ask-error — acceptable, and
|
|
353
|
+
// recorded in the Clarifications Q&A.
|
|
354
|
+
this.reducer.addBlock({ kind: 'notice', text: 'Context restored from history' });
|
|
355
|
+
this._persistBlocks();
|
|
356
|
+
}
|
|
357
|
+
const options = buildAskSpawnOptions({
|
|
358
|
+
thread: { id: this.threadId, sessionId: isRetry ? null : this.resumeSessionId }, // B-7: the only no-resume lever
|
|
359
|
+
turn: {
|
|
360
|
+
prompt: isRetry ? this.restoredPrompt : this.prompt,
|
|
361
|
+
systemPrompt: this.systemPrompt,
|
|
362
|
+
model: this.model,
|
|
363
|
+
effort: this.effort,
|
|
364
|
+
modelEnv: d.resolveModelEnv(this.model),
|
|
365
|
+
mock: this.mock, // R-F: markers on EVERY attempt
|
|
366
|
+
signal: this.abort.signal,
|
|
367
|
+
onEvent: (e) => {
|
|
368
|
+
if (e && e.type === 'session' && typeof e.sessionId === 'string' && e.sessionId) {
|
|
369
|
+
// §6.2.4: stored on the thread immediately, not at turn end.
|
|
370
|
+
this.sessionId = e.sessionId;
|
|
371
|
+
try { d.store.updateThread(this.threadId, { sessionId: e.sessionId }); } catch { /* deleted thread */ }
|
|
372
|
+
}
|
|
373
|
+
this.reducer.push(e);
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
limits: limitsNow,
|
|
377
|
+
mcpConfigPath,
|
|
378
|
+
scratchDir,
|
|
379
|
+
});
|
|
380
|
+
try {
|
|
381
|
+
await d.runClaudeImpl(options);
|
|
382
|
+
// Resolve path. Future-proofing: if a later CLI exits 0 on a limit,
|
|
383
|
+
// the reducer still computed status/reason from the result subtype.
|
|
384
|
+
await this._settle();
|
|
385
|
+
const s = this.reducer.snapshot();
|
|
386
|
+
if (/max_turns|max_budget/.test(s.resultSubtype ?? '')) this._limitNotice(s.reason, limitsNow);
|
|
387
|
+
return await this._complete({ kind: 'done', status: s.reason ? 'stopped' : 'done', reason: s.reason ?? null });
|
|
388
|
+
} catch (err) {
|
|
389
|
+
const s = this.reducer.snapshot();
|
|
390
|
+
// R-C, literal order. (1) The abort branch FIRST — B-4: a pre-aborted
|
|
391
|
+
// runClaude throws before any init, so this must precede the resume test.
|
|
392
|
+
if (err?.name === 'AbortError') {
|
|
393
|
+
// costUsd falls out of the reducer: no `result` seen ⇒ summary.costUsd
|
|
394
|
+
// is null (spec §6.2.8); a result that DID land before the abort keeps
|
|
395
|
+
// its real cost.
|
|
396
|
+
if (this.timedOut) {
|
|
397
|
+
return await this._complete({ kind: 'error', message: 'timed out after 30 min' });
|
|
398
|
+
}
|
|
399
|
+
return await this._complete({ kind: 'done', status: 'stopped', reason: 'user' });
|
|
400
|
+
}
|
|
401
|
+
// (2) The per-turn limits — F5: exit 1, classify from the reducer.
|
|
402
|
+
if (/max_turns|max_budget/.test(s.resultSubtype ?? '')) {
|
|
403
|
+
this._limitNotice(s.reason, limitsNow);
|
|
404
|
+
return await this._complete({ kind: 'done', status: 'stopped', reason: s.reason });
|
|
405
|
+
}
|
|
406
|
+
// (3) The narrow resume-fallback predicate (F9): only a session that
|
|
407
|
+
// never produced an init or said "No conversation found".
|
|
408
|
+
if (!isRetry && this.resumeSessionId
|
|
409
|
+
&& (!s.sawInit || s.errors.some((m) => /No conversation found/.test(m)))) {
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
// (4) Everything else is a turn failure.
|
|
413
|
+
if (isRetry) {
|
|
414
|
+
try { d.store.updateThread(this.threadId, { sessionId: null }); } catch { /* deleted */ }
|
|
415
|
+
}
|
|
416
|
+
return await this._complete({
|
|
417
|
+
kind: 'error',
|
|
418
|
+
message: err?.message || String(err),
|
|
419
|
+
errorClass: err?.errorClass ?? undefined,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/* c8 ignore next */
|
|
424
|
+
return { status: this.status };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
_kickoffTitle() {
|
|
428
|
+
if (!this.firstTurn || this._titleKicked) return;
|
|
429
|
+
this._titleKicked = true;
|
|
430
|
+
const d = this.deps;
|
|
431
|
+
// Fire-and-forget: kicked off at the START of the first turn (right after
|
|
432
|
+
// the scratch dir exists) and backstopped after its terminal status (§7.4).
|
|
433
|
+
// Stored for test determinism, never awaited by run() (orchestrator.mjs:3821).
|
|
434
|
+
// NO signal: a user stop aborts this.abort mid-turn and would kill the call
|
|
435
|
+
// before it spawns. permissionMode 'dontAsk' is the B-1 fix.
|
|
436
|
+
this.titlePromise = Promise.resolve()
|
|
437
|
+
.then(() => d.generateTitle(this.firstText, {
|
|
438
|
+
cwd: this.scratchDir || join(d.worcaHome(), 'tmp', 'ask'),
|
|
439
|
+
tools: [], strictMcpConfig: true, settingSources: ['project'],
|
|
440
|
+
disableSlashCommands: true, envScrub: true, envAllowlist: [],
|
|
441
|
+
permissionMode: 'dontAsk',
|
|
442
|
+
}))
|
|
443
|
+
.then((generated) => {
|
|
444
|
+
// The route stamps NOTHING before the 202 (the header reads "Ask Worca"
|
|
445
|
+
// until this frame lands), so an empty result — generateTitle swallows
|
|
446
|
+
// every failure/abort/refusal into '' — falls back to the route's
|
|
447
|
+
// deterministicTitle (sanitized first 80 chars, or "New chat"). That is
|
|
448
|
+
// the ONLY moment the prompt text may become the title.
|
|
449
|
+
const title = generated || this.deterministicTitle;
|
|
450
|
+
if (!title) return;
|
|
451
|
+
// `onlyIf: null` (title IS NULL) is the rename guard: a PATCHed or
|
|
452
|
+
// deleted thread makes the UPDATE match 0 rows and the frame is suppressed.
|
|
453
|
+
let applied = false;
|
|
454
|
+
try { applied = d.store.setThreadTitle(this.threadId, title, { onlyIf: null }); }
|
|
455
|
+
catch { /* deleted thread */ }
|
|
456
|
+
if (applied) {
|
|
457
|
+
try { d.onOutOfTurn({ type: 'ask-title', title }); } catch { /* sink */ }
|
|
458
|
+
}
|
|
459
|
+
})
|
|
460
|
+
.catch(() => { /* generateTitle already swallows; final backstop */ });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// src/core/ask/worktree-deps.mjs
|
|
2
|
+
// The WRITE-CAPABLE dep bundle of the worktree tools (ask-worca-worktrees-
|
|
3
|
+
// design.md §8). Deliberately separate from tool-deps.mjs, whose source is
|
|
4
|
+
// scanned as read-only: everything that creates/removes checkouts or updates
|
|
5
|
+
// ask_worktrees rows is reachable ONLY through here, and
|
|
6
|
+
// test/ask-worktree-tools.test.mjs pins this module's import surface.
|
|
7
|
+
import {
|
|
8
|
+
openAskWorktree, listAskWorktrees, getAskWorktree, removeAskWorktree,
|
|
9
|
+
noteWorktreeNavigation,
|
|
10
|
+
} from './worktrees.mjs';
|
|
11
|
+
import { runGitCapture } from '../worktree.mjs';
|
|
12
|
+
import { validateGitArgs } from './git-allowlist.mjs';
|
|
13
|
+
|
|
14
|
+
/** @param {{threadId:string}} opts every operation is scoped to this thread */
|
|
15
|
+
export function defaultWorktreeDeps({ threadId }) {
|
|
16
|
+
return {
|
|
17
|
+
worktrees: {
|
|
18
|
+
open: (input) => openAskWorktree({ ...input, threadId }),
|
|
19
|
+
list: () => listAskWorktrees(threadId),
|
|
20
|
+
get: (wtId) => getAskWorktree(threadId, wtId),
|
|
21
|
+
remove: (wtId) => removeAskWorktree({ threadId, wtId }),
|
|
22
|
+
noteNav: (wtId, patch) => noteWorktreeNavigation(threadId, wtId, patch),
|
|
23
|
+
runGit: (cwd, args, opts) => runGitCapture(cwd, args, opts), // opts passthrough (timeout) for callers; the tool dispatcher passes none today
|
|
24
|
+
validateGitArgs,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|