@evandu/super-pi 0.1.160 → 0.1.162
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/agents/super-pi-task/core.ts +1 -1
- package/agents/super-pi-task/index.ts +2 -1
- package/dist/client/.super-pi-client-sources.json +5 -5
- package/dist/client/assets/{CodeMirrorEditor-DeH4azrn.js → CodeMirrorEditor-Ch1Ipz3r.js} +2 -2
- package/dist/client/assets/{dist-CdG0dOlF.js → dist-B0NwzG2K.js} +1 -1
- package/dist/client/assets/{dist-Bl20l56d.js → dist-BAxkLl0w.js} +1 -1
- package/dist/client/assets/{dist-CNdHgilU.js → dist-BGN6sRpP.js} +1 -1
- package/dist/client/assets/{dist-7jfYXjWH.js → dist-B_k2ubHz.js} +1 -1
- package/dist/client/assets/{dist-DefZp4Fa.js → dist-BelPaaJY.js} +1 -1
- package/dist/client/assets/{dist-DjepRcJQ.js → dist-BofmoyAA.js} +1 -1
- package/dist/client/assets/{dist-u0a7h80y.js → dist-BucgltXJ.js} +1 -1
- package/dist/client/assets/{dist-QI-T3k9i.js → dist-COh20l57.js} +1 -1
- package/dist/client/assets/{dist-CgShg9H1.js → dist-CZfh2TpC.js} +1 -1
- package/dist/client/assets/{dist-Fa50HH8A.js → dist-C_rEORol.js} +1 -1
- package/dist/client/assets/{dist-CBAhomD3.js → dist-Chs1uuQX.js} +1 -1
- package/dist/client/assets/{dist-CsDwtK1F.js → dist-CmFGh26v.js} +1 -1
- package/dist/client/assets/{dist-BotfOUXo.js → dist-Cp0He9AW.js} +1 -1
- package/dist/client/assets/{dist-CrwnaHWk.js → dist-D5kM2io7.js} +1 -1
- package/dist/client/assets/{dist-CrIX2hon.js → dist-DsvKYPCw.js} +1 -1
- package/dist/client/assets/{dist-C1FnggSV.js → dist-EWYaYkiN.js} +1 -1
- package/dist/client/assets/{dist-BeLF-5JB.js → dist-L0PobKDk.js} +1 -1
- package/dist/client/assets/{dist-CA4ywFCU.js → dist-MIa-fDZ9.js} +1 -1
- package/dist/client/assets/{dist-C1iGzBNB.js → dist-PaqaOBBw.js} +1 -1
- package/dist/client/assets/{dist-DWHnCS4H.js → dist-opu-Ndfn.js} +1 -1
- package/dist/client/assets/{dist-Br9aGZZB.js → dist-phbezeQa.js} +1 -1
- package/dist/client/assets/{dist-ClXbSHKM.js → dist-sV52WV4g.js} +1 -1
- package/dist/client/assets/{index-D3XEFVty.js → index-PN2dNRnQ.js} +10 -10
- package/dist/client/assets/{index-CYvr3uNw.css → index-unAm6EaY.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/application/conversation-service.d.ts +6 -0
- package/dist/server/application/conversation-service.js +6 -1
- package/dist/server/application/project-service.d.ts +86 -1
- package/dist/server/application/project-service.js +389 -19
- package/dist/server/application/session-orchestrator-service.d.ts +9 -0
- package/dist/server/application/session-orchestrator-service.js +1 -0
- package/dist/server/http-server.js +17 -0
- package/dist/server/store.d.ts +1 -0
- package/dist/server/store.js +8 -1
- package/dist/server/types.d.ts +36 -0
- package/package.json +3 -2
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
const stamp = () => new Date().toISOString();
|
|
3
|
+
/**
|
|
4
|
+
* WHAT THE PROJECT LEADER IS TOLD WHEN IT STARTS.
|
|
5
|
+
*
|
|
6
|
+
* The two prompts are kept apart on purpose. The standard one is the shape the project flow has
|
|
7
|
+
* always had - decompose, delegate, review - and it is not touched. The lightweight one is a
|
|
8
|
+
* different set of instructions about a different shape: one session, no decomposition, no
|
|
9
|
+
* reviewer. Writing one prompt with holes in it would mean every sentence had to carry a
|
|
10
|
+
* condition, and the model would have to work out which shape it was in.
|
|
11
|
+
*/
|
|
12
|
+
const STANDARD_LEADER_PROMPT = "你是直接向 Boss 负责的项目 Leader,兼任秘书。先调用 project(action=get, projectId) 了解最新状态。小项目只拆最少必要任务,大项目按交付边界分工。用 add_task 登记任务(明确目标、交付物、验收标准、依赖);add_task / update_task 时可以带 executorProfile 与 reviewerProfile(支持指定 agentId、cwd、model、thinkingLevel、watchdogTimeoutSeconds),按任务性质将执行与复核委派给最合适的员工,不指定则继承项目默认;需要继续拆解的工作设 role=leader,叶子执行任务设 role=executor,子 Leader 用 parentTaskId 登记自己的子任务,dispatch 启动执行子会话;不要用 session_spawn/session_dispatch 或 task/workflow 绕过项目资源限制。收到交付后用 request_review 启动独立复核。子会话结束会自动回报给你,届时继续派发 ready 任务、复核待验收任务或按 review.summary 返工。不要轮询等待,不要反复无进展重试,并发满时结束当前回复等待回报。用 report 登记关键进展,用 escalate 将重大偏差、权限或预算问题交给 Boss,不得自行解决升级事项。所有任务验收后,用 complete 提交带证据的最终交付总结。上下文有限,明确假设,主动寻找反证。只在本次触发范围内工作。";
|
|
13
|
+
const LIGHTWEIGHT_LEADER_PROMPT = "你是直接向 Boss 负责的项目 Leader,这是一个轻量项目:这个项目由你亲自完成,不要拆子任务、不要派发给别人、也没有独立复核。先调用 project(action=get, projectId) 了解最新状态。用 add_task 登记任务(明确目标、交付物、验收标准、依赖),任务一律登记为执行任务:role=leader 或带 parentTaskId 都会被拒绝。dispatch 会把任务交回给你自己,你亲自做完;做完后调用 project(action=submit, projectId, taskId, output, evidenceRefs) 提交交付,提交即完成,不会有人回来复核,证据必须附上。不要用 session_spawn/session_dispatch 或 task/workflow 绕过项目资源限制。不要轮询等待,不要反复无进展重试。用 report 登记关键进展,用 escalate 将重大偏差、权限或预算问题交给 Boss,不得自行解决升级事项。所有任务交付后,用 complete 提交带证据的最终交付总结。上下文有限,明确假设,主动寻找反证。只在本次触发范围内工作。";
|
|
3
14
|
const text = (value, label) => {
|
|
4
15
|
if (typeof value !== "string" || !value.trim())
|
|
5
16
|
throw new Error(`${label} 不能为空`);
|
|
@@ -100,7 +111,60 @@ export const mergeProfile = (taskProfile, projectDefault) => {
|
|
|
100
111
|
const closed = (p) => ["completed", "cancelled", "failed"].includes(p.status);
|
|
101
112
|
const busy = (status) => ["running", "waiting", "awaiting_background"].includes(status ?? "");
|
|
102
113
|
const dependenciesComplete = (p, t) => t.dependencies.every((id) => p.tasks.find((candidate) => candidate.id === id)?.status === "completed");
|
|
103
|
-
|
|
114
|
+
// THE BUDGET IS COUNTED IN BILLING-EQUIVALENT TOKENS, NOT RAW TOKENS. A cache read costs a
|
|
115
|
+
// fraction of a fresh input token (0.1x on every provider we know), and a cache write costs a
|
|
116
|
+
// little more than one (1.25x). Counting them at 1:1 inflates an agent run by an order of
|
|
117
|
+
// magnitude, so a budget written by intuition always blows up. The formula is:
|
|
118
|
+
// input + output + cacheWrite * 1.25 + cacheRead * 0.1
|
|
119
|
+
// and it is shown to the reader, because a budget nobody can predict is a budget nobody trusts.
|
|
120
|
+
const CACHE_READ_WEIGHT = 0.1;
|
|
121
|
+
const CACHE_WRITE_WEIGHT = 1.25;
|
|
122
|
+
const tokens = (usage) => usage ? Math.round(usage.input + usage.output + usage.cacheWrite * CACHE_WRITE_WEIGHT + usage.cacheRead * CACHE_READ_WEIGHT) : 0;
|
|
123
|
+
/** The budget a project starts with, and the floor the one-click raise falls back to. */
|
|
124
|
+
const DEFAULT_MAX_TOKENS = 100_000;
|
|
125
|
+
// THE WORDS THE REPORT SPEAKS IN. A reader of the report is a person, not a console, so every
|
|
126
|
+
// state and every role is written the way a person would say it out loud.
|
|
127
|
+
const PROJECT_STATUS_TEXT = {
|
|
128
|
+
planning: "草稿", active: "进行中", blocked: "已阻塞", awaiting_approval: "等待审批",
|
|
129
|
+
awaiting_review: "等待复核", completed: "已完成", cancelled: "已取消", failed: "失败",
|
|
130
|
+
};
|
|
131
|
+
const TASK_STATUS_TEXT = {
|
|
132
|
+
pending: "等待依赖", ready: "待派发", in_progress: "执行中", blocked: "已阻塞",
|
|
133
|
+
awaiting_review: "待复核", completed: "已完成", failed: "失败", cancelled: "已取消",
|
|
134
|
+
};
|
|
135
|
+
const EVENT_TEXT = {
|
|
136
|
+
created: "创建项目", started: "启动执行", task_created: "登记任务", dispatched: "派发执行",
|
|
137
|
+
submitted: "提交交付", reviewed: "复核", review_requested: "启动复核", task_updated: "任务更新", status: "状态变更",
|
|
138
|
+
budget: "预算变更", report: "进展汇报", escalated: "例外升级", decision: "决定",
|
|
139
|
+
completed: "项目完成", ready: "依赖就绪",
|
|
140
|
+
};
|
|
141
|
+
const ESCALATION_STATUS_TEXT = { open: "未决", resolved: "已解决", dismissed: "已忽略" };
|
|
142
|
+
const SEVERITY_TEXT = { low: "低", medium: "中", high: "高" };
|
|
143
|
+
const REPORT_KIND_TEXT = {
|
|
144
|
+
progress: "进展", blocked: "阻塞", delivery: "交付", decision_request: "决策请求", review: "复核",
|
|
145
|
+
};
|
|
146
|
+
/** ONE LINE, ONE MEANING: a value that carries newlines must not break the layout of the report. */
|
|
147
|
+
const oneLine = (value) => value.replace(/\r?\n+/g, " ").trim();
|
|
148
|
+
/** A table cell must not carry a pipe of its own, or the row it sits in falls apart. */
|
|
149
|
+
const cell = (value) => oneLine(value).replace(/\|/g, "\\|");
|
|
150
|
+
/** A long delivery is trimmed, and the trim says so - a silent cut would read as the whole truth. */
|
|
151
|
+
const truncate = (value, limit) => value.length > limit ? `${value.slice(0, limit)}…(已截断)` : value;
|
|
152
|
+
/** A timestamp a person can read at a glance, and a missing one that says so. */
|
|
153
|
+
const moment = (value) => value ? value.replace("T", " ").replace(/\.\d+Z$/, "") : "无";
|
|
154
|
+
/**
|
|
155
|
+
* THE TWO SHAPES A PROJECT CAN HAVE.
|
|
156
|
+
*
|
|
157
|
+
* A standard project spends three sessions on every piece of work - the leader, the worker, and
|
|
158
|
+
* the reviewer - and pays two handovers for it. That is the right shape when the work needs an
|
|
159
|
+
* independent check, and it is too expensive for a project with one small task in it: the cost of
|
|
160
|
+
* the project flow then exceeds the cost of the work.
|
|
161
|
+
*
|
|
162
|
+
* A lightweight project collapses the flow to the leader session: it IS the worker, and a
|
|
163
|
+
* delivery is accepted on the executor's own evidence. Every guardrail is unchanged - the budget,
|
|
164
|
+
* the concurrency cap, the mandatory evidence, the escalation path, the event timeline - because
|
|
165
|
+
* what makes a project a project is the guardrails, not the number of sessions.
|
|
166
|
+
*/
|
|
167
|
+
const lightweight = (p) => p.mode === "lightweight";
|
|
104
168
|
/** Persist explicit domain fields only; transport credentials never enter project records. */
|
|
105
169
|
export class ProjectService {
|
|
106
170
|
store;
|
|
@@ -125,12 +189,27 @@ export class ProjectService {
|
|
|
125
189
|
return t;
|
|
126
190
|
}
|
|
127
191
|
projectView(p) {
|
|
192
|
+
const resources = this.resources(p);
|
|
128
193
|
return {
|
|
129
194
|
...p,
|
|
130
|
-
resources
|
|
195
|
+
resources,
|
|
196
|
+
suggestedMaxTokens: this.suggestedMaxTokens(p, resources.tokens),
|
|
131
197
|
tasks: p.tasks.map((t) => ProjectService.taskView(t, p)),
|
|
132
198
|
};
|
|
133
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* THE NUMBER THE ONE-CLICK PATH OFFERS.
|
|
202
|
+
*
|
|
203
|
+
* The Boss does not type a budget when the budget runs out: a number typed in a hurry is a
|
|
204
|
+
* number nobody predicted, and a budget that is guessed wrong is the failure this whole path
|
|
205
|
+
* exists to remove. So the server suggests one instead - double what has already been spent,
|
|
206
|
+
* because an agent run that needed twice its first budget will need twice its second one, and
|
|
207
|
+
* never less than one token more than the current cap, so the offer always really raises it.
|
|
208
|
+
*/
|
|
209
|
+
suggestedMaxTokens(p, usedTokens) {
|
|
210
|
+
const current = p.budget?.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
211
|
+
return Math.max(current + 1, usedTokens * 2);
|
|
212
|
+
}
|
|
134
213
|
raw(id, workspaceId) {
|
|
135
214
|
const scope = this.workspace(workspaceId);
|
|
136
215
|
const p = this.store.project(id);
|
|
@@ -192,9 +271,30 @@ export class ProjectService {
|
|
|
192
271
|
throw new Error("仅直属 Leader 可以管理此任务");
|
|
193
272
|
return manager;
|
|
194
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* DOES THIS TASK COUNT AS ACCEPTED?
|
|
276
|
+
*
|
|
277
|
+
* In a STANDARD project, acceptance means another session reviewed this delivery: the reviewer
|
|
278
|
+
* of the review must be the session that was spawned to review, and it must NOT be the session
|
|
279
|
+
* that did the work. Those conditions are the whole point of an independent review, so they
|
|
280
|
+
* stand exactly as they were.
|
|
281
|
+
*
|
|
282
|
+
* In a LIGHTWEIGHT project there is no reviewer session at all - the leader session does the
|
|
283
|
+
* work itself - so a delivery is accepted on the executor's own evidence. The flag that says so
|
|
284
|
+
* is written by `submit` and nowhere else, and it names the session that made the call, so a
|
|
285
|
+
* task with no accepted review at all still counts for nothing.
|
|
286
|
+
*/
|
|
287
|
+
taskAccepted(t) {
|
|
288
|
+
if (t.status !== "completed")
|
|
289
|
+
return false;
|
|
290
|
+
if (t.review?.lightweight === true)
|
|
291
|
+
return Boolean(t.review.reviewerConversationId);
|
|
292
|
+
return t.review?.verdict === "accepted" && t.review.reviewerConversationId === t.reviewerConversationId
|
|
293
|
+
&& Boolean(t.reviewerConversationId) && t.reviewerConversationId !== t.ownerConversationId;
|
|
294
|
+
}
|
|
195
295
|
requiredComplete(p, parentTaskId) {
|
|
196
296
|
const required = p.tasks.filter((t) => t.parentTaskId === parentTaskId && (t.status !== "cancelled" || !t.blockedReason));
|
|
197
|
-
return required.length > 0 && required.every((t) =>
|
|
297
|
+
return required.length > 0 && required.every((t) => this.taskAccepted(t));
|
|
198
298
|
}
|
|
199
299
|
task(p, taskId) {
|
|
200
300
|
const t = p.tasks.find((item) => item.id === taskId);
|
|
@@ -233,12 +333,22 @@ export class ProjectService {
|
|
|
233
333
|
const coordinatorIds = new Set([p.ownerConversationId, ...p.tasks.filter((t) => t.role === "leader").map((t) => t.ownerConversationId)].filter(Boolean));
|
|
234
334
|
const sessions = state.conversations.filter((c) => c.workspaceId === p.workspaceId && members.has(c.id)).map((c) => ({
|
|
235
335
|
id: c.id, title: c.title, status: c.status, tokens: tokens(c.usage), contextPercent: c.contextUsage?.percent,
|
|
336
|
+
// THE TREE SKELETON. The client draws the agents of a project as a tree, and the only
|
|
337
|
+
// thing that knows who spawned whom is the conversation's own parent link. It is sent
|
|
338
|
+
// verbatim (including the undefined case, which means "this session has no parent in
|
|
339
|
+
// this collection" - i.e. it is a root of the tree).
|
|
340
|
+
parentConversationId: c.parentConversationId,
|
|
236
341
|
role: c.id === p.ownerConversationId ? "项目负责人" : coordinatorIds.has(c.id) ? "分支 Leader" : p.tasks.some((t) => t.reviewerConversationId === c.id) ? "独立复核" : "执行者",
|
|
237
342
|
}));
|
|
238
343
|
const runs = state.subagentRuns.filter((r) => members.has(r.conversationId));
|
|
344
|
+
/* A COORDINATOR IS NOT A WORKER - EXCEPT IN A LIGHTWEIGHT PROJECT, WHERE THE LEADER IS THE
|
|
345
|
+
WORKER. There the leader session does the work itself, so a project that is running must not
|
|
346
|
+
report zero running agents. The concurrency guard is not touched by this: the in-flight task
|
|
347
|
+
it counts (`reserved`) already covers the busy leader, so the cap behaves exactly as before. */
|
|
348
|
+
const leaderIsWorker = lightweight(p) && p.ownerConversationId !== undefined;
|
|
239
349
|
return { tokens: sessions.reduce((sum, s) => sum + s.tokens, 0) + runs.reduce((sum, r) => sum + tokens(r.usage), 0),
|
|
240
350
|
usageKnown: sessions.length > 0 && sessions.every((s) => Boolean(this.store.conversation(s.id)?.usage)) && runs.every((r) => Boolean(r.usage)),
|
|
241
|
-
activeWorkers: sessions.filter((s) => !coordinatorIds.has(s.id) && busy(s.status)).length + runs.filter((r) => r.status === "running").length, sessions };
|
|
351
|
+
activeWorkers: sessions.filter((s) => (!coordinatorIds.has(s.id) || (leaderIsWorker && s.id === p.ownerConversationId)) && busy(s.status)).length + runs.filter((r) => r.status === "running").length, sessions };
|
|
242
352
|
}
|
|
243
353
|
list(workspaceId) {
|
|
244
354
|
const scope = this.workspace(workspaceId);
|
|
@@ -247,6 +357,109 @@ export class ProjectService {
|
|
|
247
357
|
get(id, workspaceId) {
|
|
248
358
|
return this.projectView(this.raw(id, workspaceId));
|
|
249
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* THE PROJECT AS A REPORT A HUMAN CAN READ.
|
|
362
|
+
*
|
|
363
|
+
* Everything the panel shows in pieces - the objective, the tasks and who did them, the
|
|
364
|
+
* deliveries and their evidence, the reviews that accepted them, the exceptions and how they
|
|
365
|
+
* were decided - in one piece of Markdown, in reading order.
|
|
366
|
+
*
|
|
367
|
+
* TWO THINGS THIS REPORT REFUSES TO DO. It prints no session handle: a conversation id is a
|
|
368
|
+
* machine address, and a report full of addresses is not something a person can read or paste
|
|
369
|
+
* into a weekly note - the session's own title is used instead. And it prints no credential of
|
|
370
|
+
* any kind: the CONTENT of the tasks, events, reports and exceptions is the report, and a
|
|
371
|
+
* capability or a token that happens to live in a session profile is not content.
|
|
372
|
+
*/
|
|
373
|
+
async exportReport(id, workspaceId) {
|
|
374
|
+
const p = workspaceId === undefined ? this.store.project(id) : this.raw(id, workspaceId);
|
|
375
|
+
if (!p)
|
|
376
|
+
throw new Error("项目不存在或不属于当前工作区");
|
|
377
|
+
const store = this.store;
|
|
378
|
+
/** THE TITLE A PERSON CALLS A SESSION BY. The handle stays out of the report. */
|
|
379
|
+
const session = (conversationId) => {
|
|
380
|
+
if (!conversationId)
|
|
381
|
+
return "系统";
|
|
382
|
+
return store.conversation(conversationId)?.title ?? "未知会话";
|
|
383
|
+
};
|
|
384
|
+
/** THE ROLE A TASK PLAYS, in the words the report uses. */
|
|
385
|
+
const role = (t) => t.role === "leader" ? "负责人" : t.parentTaskId ? "执行者" : "负责人直属";
|
|
386
|
+
/** THE ACCEPTANCE OF A TASK, told the truth about what is behind it. */
|
|
387
|
+
const acceptance = (t) => {
|
|
388
|
+
if (t.review?.lightweight === true)
|
|
389
|
+
return "自评交付(轻量项目,无独立复核)";
|
|
390
|
+
if (!t.review)
|
|
391
|
+
return "未验收";
|
|
392
|
+
return oneLine(t.review.summary);
|
|
393
|
+
};
|
|
394
|
+
/** THE TIMES A DELIVERY WAS SENT BACK. The current review only holds the last word, so the
|
|
395
|
+
* earlier rejections are read back out of the timeline - the event summary carries the exact
|
|
396
|
+
* text the reviewer wrote that day. */
|
|
397
|
+
const reworkNotes = (t) => {
|
|
398
|
+
const prefix = `退回返工:${t.title};`;
|
|
399
|
+
return (p.events ?? []).filter((e) => e.kind === "reviewed" && e.taskId === t.id && e.summary.startsWith(prefix))
|
|
400
|
+
.map((e) => e.summary.slice(prefix.length).trim());
|
|
401
|
+
};
|
|
402
|
+
const lines = [];
|
|
403
|
+
lines.push(`# ${oneLine(p.title)}`, "", `> ${oneLine(p.objective)}`);
|
|
404
|
+
if (p.deliverables.length)
|
|
405
|
+
lines.push(`> 交付物:${p.deliverables.map(oneLine).join(";")}`);
|
|
406
|
+
if (p.acceptanceCriteria.length)
|
|
407
|
+
lines.push(`> 验收标准:${p.acceptanceCriteria.map(oneLine).join(";")}`);
|
|
408
|
+
lines.push("", "## 概览", "");
|
|
409
|
+
lines.push(`- 状态:${PROJECT_STATUS_TEXT[p.status] ?? p.status}`);
|
|
410
|
+
lines.push(`- 模式:${lightweight(p) ? "轻量" : "标准"}`);
|
|
411
|
+
lines.push(`- 风险:${SEVERITY_TEXT[p.risk] ?? p.risk}`);
|
|
412
|
+
lines.push(`- 创建时间:${moment(p.createdAt)}`);
|
|
413
|
+
lines.push(`- 最后更新时间:${moment(p.updatedAt)}`);
|
|
414
|
+
if (p.closedAt)
|
|
415
|
+
lines.push(`- 完成时间:${moment(p.closedAt)}`);
|
|
416
|
+
lines.push(`- 预算上限:${p.budget?.maxTokens ?? DEFAULT_MAX_TOKENS} Token`);
|
|
417
|
+
// THE USAGE IS REPORTED IN THE SAME BILLING-EQUIVALENT UNITS THE BUDGET IS SPENT IN, and the
|
|
418
|
+
// formula is printed next to the number: a budget nobody can predict is a budget nobody trusts.
|
|
419
|
+
lines.push(`- 已用等效 Token:${this.resources(p).tokens}(口径:输入 + 输出 + 缓存写入×${CACHE_WRITE_WEIGHT} + 缓存读取×${CACHE_READ_WEIGHT},取整)`);
|
|
420
|
+
lines.push(`- 任务:共 ${p.tasks.length} 个,已完成 ${p.tasks.filter((t) => t.status === "completed").length} 个`);
|
|
421
|
+
lines.push("", "## 交付与验收", "");
|
|
422
|
+
if (!p.tasks.length)
|
|
423
|
+
lines.push("无");
|
|
424
|
+
p.tasks.forEach((t, index) => {
|
|
425
|
+
lines.push(`### ${index + 1}. ${oneLine(t.title)}`, "");
|
|
426
|
+
lines.push(`- 角色:${role(t)}`);
|
|
427
|
+
lines.push(`- 状态:${TASK_STATUS_TEXT[t.status] ?? t.status}`);
|
|
428
|
+
lines.push(`- 负责人:${t.ownerConversationId ? session(t.ownerConversationId) : "未派发"}`);
|
|
429
|
+
lines.push(`- 复核者:${t.reviewerConversationId ? session(t.reviewerConversationId) : "无"}`);
|
|
430
|
+
lines.push(`- 交付内容:${t.output ? truncate(oneLine(t.output), 200) : "无"}`);
|
|
431
|
+
lines.push(`- 证据:${t.evidenceRefs?.length ? t.evidenceRefs.map(oneLine).join("、") : "无"}`);
|
|
432
|
+
lines.push(`- 验收结论:${acceptance(t)}`);
|
|
433
|
+
if (t.review?.evidenceRefs?.length)
|
|
434
|
+
lines.push(`- 复核证据:${t.review.evidenceRefs.map(oneLine).join("、")}`);
|
|
435
|
+
const notes = reworkNotes(t);
|
|
436
|
+
if (notes.length)
|
|
437
|
+
lines.push(`- 退回意见:${notes.join(";")}`);
|
|
438
|
+
lines.push("");
|
|
439
|
+
});
|
|
440
|
+
lines.push("## 例外与决定", "");
|
|
441
|
+
if (!p.escalations.length)
|
|
442
|
+
lines.push("无");
|
|
443
|
+
else {
|
|
444
|
+
lines.push("| 例外 | 严重程度 | 状态 | 决定 | 解决时间 |", "| --- | --- | --- | --- | --- |");
|
|
445
|
+
for (const e of p.escalations) {
|
|
446
|
+
lines.push(`| ${cell(e.reason)} | ${SEVERITY_TEXT[e.severity] ?? e.severity} | ${ESCALATION_STATUS_TEXT[e.status] ?? e.status} | ${cell(e.decision ?? "无")} | ${e.resolvedAt ? moment(e.resolvedAt) : "无"} |`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
lines.push("", "## 时间线", "");
|
|
450
|
+
const events = [...(p.events ?? [])].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
|
451
|
+
if (!events.length)
|
|
452
|
+
lines.push("无");
|
|
453
|
+
for (const e of events)
|
|
454
|
+
lines.push(`- ${moment(e.createdAt)} · ${EVENT_TEXT[e.kind] ?? e.kind} · ${oneLine(e.summary)} · ${session(e.actorConversationId)}`);
|
|
455
|
+
lines.push("", "## 最近汇报", "");
|
|
456
|
+
if (!p.reports.length)
|
|
457
|
+
lines.push("无");
|
|
458
|
+
for (const r of p.reports) {
|
|
459
|
+
lines.push(`- ${REPORT_KIND_TEXT[r.kind] ?? r.kind}:${oneLine(r.progress)} | 偏差:${r.variance ? oneLine(r.variance) : "无"} | 下一步:${r.nextStep ? oneLine(r.nextStep) : "无"} | 风险:${r.risks?.length ? r.risks.map(oneLine).join("、") : "无"}`);
|
|
460
|
+
}
|
|
461
|
+
return `${lines.join("\n")}\n`;
|
|
462
|
+
}
|
|
250
463
|
projectForSession(conversationId) {
|
|
251
464
|
let c = this.store.conversation(conversationId);
|
|
252
465
|
const seen = new Set();
|
|
@@ -265,6 +478,10 @@ export class ProjectService {
|
|
|
265
478
|
throw new Error("项目负责人由启动操作创建独立会话");
|
|
266
479
|
if (input.risk !== undefined && !["low", "medium", "high"].includes(input.risk))
|
|
267
480
|
throw new Error("项目风险等级不合法");
|
|
481
|
+
// THE MODE IS CHOSEN ONCE, AT BIRTH, AND IT IS A CLOSED SET. A typo must not silently buy a
|
|
482
|
+
// project the standard shape, because the standard shape is the one with the reviewer.
|
|
483
|
+
if (input.mode !== undefined && input.mode !== "standard" && input.mode !== "lightweight")
|
|
484
|
+
throw new Error("项目模式不合法:只能是 standard 或 lightweight");
|
|
268
485
|
if (input.budget !== undefined && (!input.budget || typeof input.budget !== "object" || Array.isArray(input.budget)))
|
|
269
486
|
throw new Error("资源预算不合法");
|
|
270
487
|
for (const [key, value] of Object.entries(input.budget ?? {})) {
|
|
@@ -280,7 +497,10 @@ export class ProjectService {
|
|
|
280
497
|
const now = stamp();
|
|
281
498
|
const p = { id: `project_${randomUUID()}`, workspaceId, title: text(input.title, "项目标题"), objective: text(input.objective, "项目目标"),
|
|
282
499
|
deliverables: strings(input.deliverables, "交付物"), acceptanceCriteria: strings(input.acceptanceCriteria, "验收标准"), constraints: strings(input.constraints, "约束"),
|
|
283
|
-
risk: input.risk ?? "low", status: "planning", budget: { maxTokens: input.budget?.maxTokens ??
|
|
500
|
+
risk: input.risk ?? "low", status: "planning", budget: { maxTokens: input.budget?.maxTokens ?? DEFAULT_MAX_TOKENS, maxConcurrent: input.budget?.maxConcurrent ?? 2 },
|
|
501
|
+
// A project without a mode is a standard project: the mode is written down only when it was
|
|
502
|
+
// asked for, so every project that existed before the mode existed reads as standard.
|
|
503
|
+
...(input.mode ? { mode: input.mode } : {}),
|
|
284
504
|
...(defaultLeaderProfile ? { defaultLeaderProfile } : {}),
|
|
285
505
|
...(defaultExecutorProfile ? { defaultExecutorProfile } : {}),
|
|
286
506
|
...(defaultReviewerProfile ? { defaultReviewerProfile } : {}),
|
|
@@ -338,6 +558,12 @@ export class ProjectService {
|
|
|
338
558
|
// project, so it is where the project belongs in the tree. A project the Boss started from
|
|
339
559
|
// the panel has no initiator, and stays a root session exactly as before.
|
|
340
560
|
...(p.initiatorConversationId ? { parentConversationId: p.initiatorConversationId } : {}),
|
|
561
|
+
// A PROJECT IS ITS OWN WORKING CONTEXT. The leader hangs under the conversation that
|
|
562
|
+
// asked for the project, but it must NOT share that conversation's memory tree: a tree
|
|
563
|
+
// is what makes the memories of a chat visible to the sessions below it, and the
|
|
564
|
+
// project's workers are not part of the user's chat. The tree edge is for the SIDEBAR
|
|
565
|
+
// and the memory tree is for the WORK - they are two different things.
|
|
566
|
+
separateMemoryTree: true,
|
|
341
567
|
title: `项目负责人 · ${p.title}`,
|
|
342
568
|
task: p.objective,
|
|
343
569
|
initialPrompt: "",
|
|
@@ -351,7 +577,7 @@ export class ProjectService {
|
|
|
351
577
|
this.event(p, "started", "项目负责人开始组织执行", undefined, actor);
|
|
352
578
|
p.status = "active";
|
|
353
579
|
await this.store.setProject(p);
|
|
354
|
-
await this.orchestrator.dispatch(p.ownerConversationId, `${this.context(p)}\n
|
|
580
|
+
await this.orchestrator.dispatch(p.ownerConversationId, `${this.context(p)}\n${lightweight(p) ? LIGHTWEIGHT_LEADER_PROMPT : STANDARD_LEADER_PROMPT}`, { wait: false });
|
|
355
581
|
return p;
|
|
356
582
|
});
|
|
357
583
|
}
|
|
@@ -359,6 +585,15 @@ export class ProjectService {
|
|
|
359
585
|
return this.change(id, workspaceId, (p) => {
|
|
360
586
|
this.live(p);
|
|
361
587
|
const parentTaskId = optional(input.parentTaskId, "父任务 id");
|
|
588
|
+
// A LIGHTWEIGHT PROJECT HAS ONE SESSION, SO IT HAS NO TREE OF TASKS. Decomposing the work
|
|
589
|
+
// would mean spawning the worker sessions the mode exists to avoid, and a branch leader
|
|
590
|
+
// would mean a second one. Both are refused here, in words the caller can act on.
|
|
591
|
+
if (lightweight(p)) {
|
|
592
|
+
if (parentTaskId)
|
|
593
|
+
throw new Error("轻量项目不再拆子任务:整个项目由负责人会话自己完成");
|
|
594
|
+
if (input.role === "leader")
|
|
595
|
+
throw new Error("轻量项目没有分支 Leader:请登记为执行任务,由负责人亲自完成");
|
|
596
|
+
}
|
|
362
597
|
// A top-level task is accountable to whoever owns the project: the leader once it runs, and
|
|
363
598
|
// before that the initiator who registered it - a task nobody answers for cannot be closed.
|
|
364
599
|
let manager = p.ownerConversationId ?? p.initiatorConversationId;
|
|
@@ -429,16 +664,31 @@ export class ProjectService {
|
|
|
429
664
|
throw new Error("父任务未处于执行中");
|
|
430
665
|
const effective = mergeProfile(t.executorProfile, p.defaultExecutorProfile);
|
|
431
666
|
if (!t.ownerConversationId) {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
667
|
+
if (lightweight(p)) {
|
|
668
|
+
// THE LEADER IS THE WORKER. A lightweight project spawns no worker session, so the task's
|
|
669
|
+
// executor IS the project leader session - the very session that is asking for this
|
|
670
|
+
// dispatch. There is nothing to hand the work to otherwise.
|
|
671
|
+
if (!p.ownerConversationId)
|
|
672
|
+
throw new Error("轻量项目还没有负责人会话:请先启动项目负责人");
|
|
673
|
+
t.ownerConversationId = p.ownerConversationId;
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
t.ownerConversationId = (await this.orchestrator.spawn({
|
|
677
|
+
workspaceId, parentConversationId: manager, title: `${t.role === "leader" ? "Leader" : "执行"} · ${t.title}`, task: t.objective, initialPrompt: "",
|
|
678
|
+
...(effective?.agentId ? { agentId: effective.agentId } : {}),
|
|
679
|
+
...(effective?.cwd !== undefined ? { cwd: effective.cwd } : {}),
|
|
680
|
+
...(effective?.model ? { model: effective.model } : {}),
|
|
681
|
+
...(effective?.thinkingLevel ? { thinkingLevel: effective.thinkingLevel } : {}),
|
|
682
|
+
...(effective?.watchdogTimeoutSeconds ? { watchdogTimeoutSeconds: effective.watchdogTimeoutSeconds } : {}),
|
|
683
|
+
})).conversationId;
|
|
684
|
+
}
|
|
440
685
|
}
|
|
441
|
-
|
|
686
|
+
// THE BUSY CHECK IS ABOUT A SECOND WORKER SESSION, AND THERE IS NONE HERE. It stops a
|
|
687
|
+
// worker from being handed a second task while it is still running the first one. In a
|
|
688
|
+
// lightweight project the executor is the leader, which is busy by definition - it is the
|
|
689
|
+
// session that asked for this dispatch - and the leader's own queue serializes the tasks
|
|
690
|
+
// it has taken on.
|
|
691
|
+
if (!lightweight(p) && busy(this.store.conversation(t.ownerConversationId)?.status))
|
|
442
692
|
throw new Error("执行会话尚未结束,请等待完成回报后派发");
|
|
443
693
|
t.status = "in_progress";
|
|
444
694
|
t.updatedAt = stamp();
|
|
@@ -447,7 +697,11 @@ export class ProjectService {
|
|
|
447
697
|
this.reconcile(p);
|
|
448
698
|
await this.store.setProject(p);
|
|
449
699
|
const delegation = t.role === "leader" ? `你是分支 Leader,负责这项任务的拆解、委派、集成交付。用 project add_task 时必须带 parentTaskId="${t.id}";可按必要性指定 role="leader" 或 "executor";add_task / update_task 时可以带 executorProfile 与 reviewerProfile(支持指定 agentId、cwd、model、thinkingLevel、watchdogTimeoutSeconds),按任务性质将执行与复核委派给最合适的员工,不指定则继承项目默认。用 project dispatch / request_review 管理直属子任务,子会话结束自动回报给你,不要轮询;并发满时结束本轮等待回报。所有必需子任务独立验收后才能 submit 聚合交付,上级还会独立复核你的集成结果。最多 3 层 Leader、8 个分支 Leader;小任务不要继续拆层。` : "你是执行者,不得自行拆解子任务。";
|
|
450
|
-
|
|
700
|
+
/* THE LIGHTWEIGHT TASK SAYS WHAT IS DIFFERENT, because the difference is the point: there is
|
|
701
|
+
no reviewer coming back, so the evidence that the reviewer would have demanded is the only
|
|
702
|
+
thing that stands behind the delivery. */
|
|
703
|
+
const modeNote = lightweight(p) ? "本项目是轻量项目:你就是唯一的执行会话,不会有独立复核人回来,完成后调用 project(action=submit, …) 提交即完成,交付证据必须附上。" : "";
|
|
704
|
+
await this.orchestrator.dispatch(t.ownerConversationId, `${this.context(p)}\n${delegation}\n你负责执行任务 ${t.id}:${t.title}\n目标:${t.objective}\n交付物:${t.deliverables.join(";")}\n验收:${t.acceptanceCriteria.join(";")}\n上次复核意见与验证证据:${JSON.stringify(t.review ?? null)}\n已验收前置产物:${JSON.stringify(p.tasks.filter((d) => t.dependencies.includes(d.id)).map((d) => ({ title: d.title, output: d.output, evidenceRefs: d.evidenceRefs })))}\n你不是唯一执行者,遵守分工边界。${modeNote}完成后必须调用 project(action=submit, projectId="${p.id}", taskId="${t.id}", output="交付总结", evidenceRefs=["产物路径或验证记录"]),不能自行标记已完成。复核退回时修复后重新 submit。遇到阻塞用 report 或 escalate,保留证据、假设和未解决问题。不要自行创建其他执行会话或 task/workflow。`, { wait: false });
|
|
451
705
|
return ProjectService.taskView(t, p);
|
|
452
706
|
});
|
|
453
707
|
}
|
|
@@ -462,9 +716,26 @@ export class ProjectService {
|
|
|
462
716
|
if (t.role === "leader" && !this.requiredComplete(p, t.id))
|
|
463
717
|
throw new Error("Leader 的必需子任务尚未全部独立验收");
|
|
464
718
|
t.output = text(input.output, "交付结果");
|
|
465
|
-
|
|
719
|
+
const evidenceRefs = strings(input.evidenceRefs, "交付证据");
|
|
720
|
+
t.evidenceRefs = evidenceRefs;
|
|
721
|
+
// THE EVIDENCE IS REQUIRED IN BOTH SHAPES. The reviewer is gone in a lightweight project,
|
|
722
|
+
// and the evidence is exactly what the reviewer would have checked - so it is the one thing
|
|
723
|
+
// that does not get lighter.
|
|
466
724
|
if (!t.evidenceRefs.length)
|
|
467
725
|
throw new Error("交付必须附上产物或验证证据");
|
|
726
|
+
if (lightweight(p)) {
|
|
727
|
+
/* THE DELIVERY IS ACCEPTED ON THE EXECUTOR'S OWN EVIDENCE. There is no reviewer session to
|
|
728
|
+
send it to, so the acceptance is written down here - with the flag that says there was
|
|
729
|
+
no independent review, and with the session that made the call, so the record is not a
|
|
730
|
+
claim that somebody else checked it. */
|
|
731
|
+
t.status = "completed";
|
|
732
|
+
t.updatedAt = stamp();
|
|
733
|
+
t.review = { verdict: "accepted", summary: "轻量项目:执行者自评交付,未做独立复核", evidenceRefs,
|
|
734
|
+
reviewerConversationId: t.ownerConversationId, createdAt: stamp(), lightweight: true };
|
|
735
|
+
this.event(p, "submitted", `轻量交付完成:${t.title}`, t.id, actor);
|
|
736
|
+
this.reconcile(p);
|
|
737
|
+
return ProjectService.taskView(t, p);
|
|
738
|
+
}
|
|
468
739
|
t.status = "awaiting_review";
|
|
469
740
|
t.updatedAt = stamp();
|
|
470
741
|
delete t.review;
|
|
@@ -475,6 +746,10 @@ export class ProjectService {
|
|
|
475
746
|
}
|
|
476
747
|
async requestReview(id, taskId, workspaceId, actor) {
|
|
477
748
|
return this.change(id, workspaceId, async (p) => {
|
|
749
|
+
/* THERE IS NO REVIEWER TO ASK FOR. A lightweight project's delivery is accepted when it is
|
|
750
|
+
submitted, so asking for a review is asking for a session that will never exist. */
|
|
751
|
+
if (lightweight(p))
|
|
752
|
+
throw new Error("轻量项目不做独立复核:提交即完成");
|
|
478
753
|
this.admission(p);
|
|
479
754
|
const t = this.task(p, taskId);
|
|
480
755
|
const manager = this.manager(p, t, actor);
|
|
@@ -639,6 +914,48 @@ export class ProjectService {
|
|
|
639
914
|
await this.orchestrator?.stopSubtree?.(result.ownerConversationId);
|
|
640
915
|
return result;
|
|
641
916
|
}
|
|
917
|
+
/**
|
|
918
|
+
* THE SESSION THAT OPENED THE PROJECT MAY CANCEL IT.
|
|
919
|
+
*
|
|
920
|
+
* There is no delete here, and that is deliberate. The project record, its tasks, its events and
|
|
921
|
+
* its evidence references are the trail of what was tried; a project that went nowhere is
|
|
922
|
+
* evidence too. Cancelling stops the work and marks the project cancelled - the record stays,
|
|
923
|
+
* readable, with the reason attached.
|
|
924
|
+
*
|
|
925
|
+
* Only the session that opened the project may do this. A worker or a reviewer that could
|
|
926
|
+
* cancel the project it belongs to would have a way to make its own budget and concurrency
|
|
927
|
+
* guards disappear, and those guards are the only thing between a runaway agent and an
|
|
928
|
+
* unbounded bill. The human path - the panel's stop button - is unchanged and stays the Boss's.
|
|
929
|
+
*
|
|
930
|
+
* A reason is required: "we changed our minds" and "the customer shipped it last night" are
|
|
931
|
+
* different decisions, and the timeline is where that difference has to show up.
|
|
932
|
+
*/
|
|
933
|
+
async cancel(id, body, workspaceId, actor) {
|
|
934
|
+
const reason = typeof body.reason === "string" ? body.reason.trim() : "";
|
|
935
|
+
if (!reason)
|
|
936
|
+
throw new Error("取消项目必须说明理由:理由会进入项目的事件时间线。");
|
|
937
|
+
const found = this.get(id, workspaceId);
|
|
938
|
+
if (!found.initiatorConversationId || found.initiatorConversationId !== actor)
|
|
939
|
+
throw new Error("只有项目的发起人可以取消它。");
|
|
940
|
+
const result = await this.change(id, workspaceId, (p) => {
|
|
941
|
+
this.live(p);
|
|
942
|
+
p.status = "cancelled";
|
|
943
|
+
if (closed(p))
|
|
944
|
+
p.closedAt = stamp();
|
|
945
|
+
for (const t of p.tasks) {
|
|
946
|
+
if (["completed", "cancelled", "failed"].includes(t.status))
|
|
947
|
+
continue;
|
|
948
|
+
t.status = "cancelled";
|
|
949
|
+
t.updatedAt = stamp();
|
|
950
|
+
}
|
|
951
|
+
this.event(p, "status", `项目发起人取消项目:${reason}`, undefined, actor);
|
|
952
|
+
return p;
|
|
953
|
+
});
|
|
954
|
+
// Stop after releasing the write lock: an interrupted tool may itself be waiting for it.
|
|
955
|
+
if (result.ownerConversationId)
|
|
956
|
+
await this.orchestrator?.stopSubtree?.(result.ownerConversationId);
|
|
957
|
+
return result;
|
|
958
|
+
}
|
|
642
959
|
async updateBudget(id, input, workspaceId) {
|
|
643
960
|
return this.change(id, workspaceId, (p) => {
|
|
644
961
|
this.live(p);
|
|
@@ -652,6 +969,56 @@ export class ProjectService {
|
|
|
652
969
|
return p;
|
|
653
970
|
});
|
|
654
971
|
}
|
|
972
|
+
/**
|
|
973
|
+
* ONE CLICK: RAISE THE BUDGET AND LET THE WORK CONTINUE.
|
|
974
|
+
*
|
|
975
|
+
* Three things happen here, and they happen together: the cap goes up to a number the SERVER
|
|
976
|
+
* computed, the budget exception that is waiting for a decision is closed, and the work that
|
|
977
|
+
* was blocked is woken up again. Doing them apart is what made the old path painful - a Boss
|
|
978
|
+
* had to open a form, type a number, press save, and then wake the leader by hand, and every
|
|
979
|
+
* one of those steps was a chance to stop halfway.
|
|
980
|
+
*
|
|
981
|
+
* IT IS A BOSS OPERATION. A session may not raise the budget of its own project: the budget is
|
|
982
|
+
* the Boss's stop, and an agent that could lift its own limit would not have one. The HTTP
|
|
983
|
+
* layer refuses a request that carries a capability for the same reason.
|
|
984
|
+
*
|
|
985
|
+
* The manual `updateBudget` stays a SEPARATE intent: adjusting a number is not the same as
|
|
986
|
+
* asking for the work to continue, so the manual path does not continue anything.
|
|
987
|
+
*/
|
|
988
|
+
async raiseBudgetAndContinue(id, workspaceId, callerConversationId) {
|
|
989
|
+
if (callerConversationId)
|
|
990
|
+
throw new Error("管理操作仅由 Boss 发起:Agent 不能提高预算,也不能替 Boss 让项目继续");
|
|
991
|
+
const raised = await this.change(id, workspaceId, (p) => {
|
|
992
|
+
this.live(p);
|
|
993
|
+
const previousMaxTokens = p.budget?.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
994
|
+
const usedTokens = this.resources(p).tokens;
|
|
995
|
+
const suggestedMaxTokens = Math.max(previousMaxTokens + 1, usedTokens * 2);
|
|
996
|
+
(p.budget ??= {}).maxTokens = suggestedMaxTokens;
|
|
997
|
+
const decision = `Boss 提高预算到 ${suggestedMaxTokens} Token 并继续`;
|
|
998
|
+
let resolvedEscalations = 0;
|
|
999
|
+
for (const e of p.escalations) {
|
|
1000
|
+
if (e.status !== "open")
|
|
1001
|
+
continue;
|
|
1002
|
+
// ONLY THE BUDGET ONES. An exception about scope or permissions is not the Boss's to
|
|
1003
|
+
// close with a budget decision, and closing it here would hide a question nobody answered.
|
|
1004
|
+
if (!e.reason.includes("预算"))
|
|
1005
|
+
continue;
|
|
1006
|
+
e.status = "resolved";
|
|
1007
|
+
e.decision = decision;
|
|
1008
|
+
e.resolvedAt = stamp();
|
|
1009
|
+
resolvedEscalations += 1;
|
|
1010
|
+
}
|
|
1011
|
+
this.event(p, "budget", `Boss 提高派发预算:${previousMaxTokens} → ${suggestedMaxTokens} Token,并继续执行(已处理 ${resolvedEscalations} 条预算例外)`);
|
|
1012
|
+
this.reconcile(p);
|
|
1013
|
+
return { previousMaxTokens, suggestedMaxTokens, resolvedEscalations };
|
|
1014
|
+
});
|
|
1015
|
+
// AND NOW LET IT MOVE. `start` is the existing way to wake the leader: it dispatches the
|
|
1016
|
+
// leader session when the project has one, and creates one when it has not. Its refusal -
|
|
1017
|
+
// "项目负责人正在执行,请勿重复启动" - is passed through unchanged: a busy leader is a fact
|
|
1018
|
+
// the Boss has to see, not an error to swallow.
|
|
1019
|
+
const project = await this.start(id, workspaceId);
|
|
1020
|
+
return { ...raised, project };
|
|
1021
|
+
}
|
|
655
1022
|
async addReport(id, input, workspaceId, actor) {
|
|
656
1023
|
return this.change(id, workspaceId, (p) => {
|
|
657
1024
|
this.live(p);
|
|
@@ -735,8 +1102,9 @@ export class ProjectService {
|
|
|
735
1102
|
this.live(p);
|
|
736
1103
|
this.leader(p, actor);
|
|
737
1104
|
const required = p.tasks.filter((t) => t.status !== "cancelled" || !t.blockedReason);
|
|
738
|
-
if (!required.length || required.some((t) =>
|
|
739
|
-
throw new Error("所有任务必须经独立复核验收后才能完成项目");
|
|
1105
|
+
if (!required.length || required.some((t) => !this.taskAccepted(t))) {
|
|
1106
|
+
throw new Error(lightweight(p) ? "所有任务必须完成交付后才能完成项目" : "所有任务必须经独立复核验收后才能完成项目");
|
|
1107
|
+
}
|
|
740
1108
|
if (p.escalations.some((e) => e.status === "open"))
|
|
741
1109
|
throw new Error("尚有未处理例外");
|
|
742
1110
|
const evidence = strings(evidenceRefs, "最终交付证据");
|
|
@@ -856,6 +1224,7 @@ export class ProjectService {
|
|
|
856
1224
|
this.member(p, actor);
|
|
857
1225
|
switch (body.action) {
|
|
858
1226
|
case "get": return p;
|
|
1227
|
+
case "export": return this.exportReport(id, workspaceId);
|
|
859
1228
|
case "start": return this.start(id, workspaceId, actor);
|
|
860
1229
|
case "add_task": return this.addTask(id, body, workspaceId, actor);
|
|
861
1230
|
case "dispatch": return this.dispatchTask(id, text(body.taskId, "taskId"), workspaceId, actor);
|
|
@@ -866,6 +1235,7 @@ export class ProjectService {
|
|
|
866
1235
|
case "report": return this.addReport(id, body, workspaceId, actor);
|
|
867
1236
|
case "escalate": return this.addEscalation(id, body, workspaceId, actor);
|
|
868
1237
|
case "complete": return this.complete(id, body.summary, body.evidenceRefs, workspaceId, actor);
|
|
1238
|
+
case "cancel": return this.cancel(id, body, workspaceId, actor);
|
|
869
1239
|
default: throw new Error("未知的项目工具操作");
|
|
870
1240
|
}
|
|
871
1241
|
}
|
|
@@ -45,6 +45,15 @@ export interface SpawnSessionOptions {
|
|
|
45
45
|
wait?: boolean;
|
|
46
46
|
/** Optional silence watchdog timeout in seconds. If set, parent is reminded if child goes quiet for this duration. */
|
|
47
47
|
watchdogTimeoutSeconds?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Whether the new session opens its OWN memory tree instead of inheriting the parent's.
|
|
50
|
+
*
|
|
51
|
+
* A memory tree is what makes one conversation's tree-scoped memories visible to the sessions
|
|
52
|
+
* under it. A project wants the opposite: the project hangs under the conversation that asked
|
|
53
|
+
* for it in the SIDEBAR, and it shares nothing of that conversation's memories. The session tree
|
|
54
|
+
* edge and the memory tree are two different things, and this flag touches only the second.
|
|
55
|
+
*/
|
|
56
|
+
separateMemoryTree?: boolean;
|
|
48
57
|
}
|
|
49
58
|
export interface ReportSessionOptions {
|
|
50
59
|
/** The report content (milestone, findings, roadblock, or final output). */
|
|
@@ -130,6 +130,7 @@ export class SessionOrchestratorService {
|
|
|
130
130
|
const conversationId = await this.conversationService.createConversation(workspaceId, {
|
|
131
131
|
parentConversationId: options.parentConversationId,
|
|
132
132
|
origin: options.parentConversationId ? "orchestrator" : "user",
|
|
133
|
+
...(options.separateMemoryTree ? { separateMemoryTree: true } : {}),
|
|
133
134
|
}, cwd);
|
|
134
135
|
// Set title if requested or derive from task
|
|
135
136
|
if (options.title) {
|
|
@@ -1551,6 +1551,15 @@ export function createHttpServer(options) {
|
|
|
1551
1551
|
json(200, { escalations: project.escalations });
|
|
1552
1552
|
return;
|
|
1553
1553
|
}
|
|
1554
|
+
// THE REPORT IS MARKDOWN, NOT JSON. It exists so a person can open the URL in a browser
|
|
1555
|
+
// and read the project, or paste it into a weekly note - a JSON body would need a reader
|
|
1556
|
+
// to have one, and the whole point is that the reader does not.
|
|
1557
|
+
if (subresource === "report") {
|
|
1558
|
+
const markdown = await service.exportReport(projectId, requestedWorkspaceId);
|
|
1559
|
+
response.writeHead(200, { "content-type": "text/markdown; charset=utf-8", "cache-control": "no-store" });
|
|
1560
|
+
response.end(markdown);
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1554
1563
|
json(404, { error: "未知的项目资源" });
|
|
1555
1564
|
return;
|
|
1556
1565
|
}
|
|
@@ -1585,6 +1594,14 @@ export function createHttpServer(options) {
|
|
|
1585
1594
|
json(200, { project });
|
|
1586
1595
|
return;
|
|
1587
1596
|
}
|
|
1597
|
+
// ONE CLICK: RAISE THE BUDGET AND CONTINUE. The server computes the new cap, closes the
|
|
1598
|
+
// budget exception and wakes the leader - the client sends no number of its own.
|
|
1599
|
+
if (request.method === "POST" && subresource === "budget" && parts[2] === "continue") {
|
|
1600
|
+
const result = await service.raiseBudgetAndContinue(projectId, workspaceId, callerConversationId);
|
|
1601
|
+
broadcaster.broadcastState();
|
|
1602
|
+
json(200, result);
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1588
1605
|
if (request.method === "POST" && subresource === "tasks" && parts[2] && parts[3] === "dispatch") {
|
|
1589
1606
|
const task = await service.dispatchTask(projectId, parts[2], workspaceId);
|
|
1590
1607
|
broadcaster.broadcastState();
|
package/dist/server/store.d.ts
CHANGED
|
@@ -281,6 +281,7 @@ export declare class Store {
|
|
|
281
281
|
cwd?: string;
|
|
282
282
|
parentConversationId?: string;
|
|
283
283
|
origin?: "user" | "orchestrator";
|
|
284
|
+
separateMemoryTree?: boolean;
|
|
284
285
|
}): Promise<Conversation>;
|
|
285
286
|
/**
|
|
286
287
|
* Records (or clears) one conversation as collapsed in the sidebar's session tree. The list is a
|