@a-t-h-i/bot-lobby 0.6.18 → 0.6.19

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 (52) hide show
  1. package/package.json +1 -1
  2. package/src/ask/relay.ts +3 -1
  3. package/src/ask/tool.ts +18 -3
  4. package/src/ask/types.ts +12 -0
  5. package/src/ask/web.ts +2 -1
  6. package/src/delivery/checks.ts +37 -0
  7. package/src/delivery/lock.ts +78 -0
  8. package/src/delivery/merge.ts +44 -0
  9. package/src/delivery/operations.ts +134 -0
  10. package/src/delivery/pr.ts +40 -0
  11. package/src/delivery/review.ts +90 -0
  12. package/src/delivery/rules.ts +46 -0
  13. package/src/delivery/source.ts +39 -0
  14. package/src/delivery/transport.ts +36 -0
  15. package/src/delivery/types.ts +60 -0
  16. package/src/execution/agent-runner.ts +1 -23
  17. package/src/index.ts +1 -1
  18. package/src/lobby/feed.ts +0 -6
  19. package/src/lobby/host.ts +3 -0
  20. package/src/lobby/planner.ts +1 -7
  21. package/src/lobby/pr-review.ts +1 -7
  22. package/src/lobby/prompt-hub.ts +7 -1
  23. package/src/lobby/quickfix.ts +3 -16
  24. package/src/lobby/service.ts +73 -3
  25. package/src/lobby/sessions.ts +12 -0
  26. package/src/lobby/task-rows.ts +5 -0
  27. package/src/master/master.ts +1 -1
  28. package/src/master/research.ts +0 -1
  29. package/src/pi/events.ts +0 -2
  30. package/src/pi/model-settings.ts +7 -26
  31. package/src/pi/model-support.ts +4 -19
  32. package/src/schemas/configuration.ts +8 -38
  33. package/src/schemas/findings.ts +0 -2
  34. package/src/schemas/task.ts +14 -0
  35. package/src/state/blocking-requests.ts +23 -0
  36. package/src/state/persistence.ts +12 -1
  37. package/src/state/phase-timing.ts +53 -0
  38. package/src/state/task-state.ts +2 -0
  39. package/src/state/timing-merge.ts +25 -0
  40. package/src/webui/api/index.ts +17 -1
  41. package/src/webui/api/tasks.ts +52 -0
  42. package/src/webui/protocol.ts +8 -0
  43. package/src/workflow/approvals.ts +3 -0
  44. package/src/workflow/workflow.ts +14 -33
  45. package/webui/dist/assets/index-BJI9lE5R.js +98 -0
  46. package/webui/dist/assets/index-Dg0tvkeY.css +2 -0
  47. package/webui/dist/build.json +83 -43
  48. package/webui/dist/index.html +2 -2
  49. package/src/execution/fallback.ts +0 -75
  50. package/src/pi/master-fallback.ts +0 -61
  51. package/webui/dist/assets/index-CHLNxlA1.js +0 -98
  52. package/webui/dist/assets/index-m9XcQvH8.css +0 -2
@@ -26,14 +26,11 @@ export interface EntryView {
26
26
  thinking?: string;
27
27
  instructions?: string;
28
28
  timeoutMs?: number;
29
- /** The model this agent switches to when its own runs out of usage; unset = none. */
30
- fallbackModel?: string;
31
- fallbackThinking?: string;
32
29
  }
33
30
 
34
31
  export function entryView(config: BotLobbyConfig, kind: SettingsKind): EntryView {
35
32
  if (kind === "master") return config.master;
36
- if (kind === "scout") return { model: config.scout.model, timeoutMs: config.scout.timeoutMs, ...(config.scout.fallbackModel ? { fallbackModel: config.scout.fallbackModel } : {}) };
33
+ if (kind === "scout") return { model: config.scout.model, timeoutMs: config.scout.timeoutMs };
37
34
  if (kind === "researcher") return config.researcher;
38
35
  if (kind === "quickfix") return config.quickFix;
39
36
  if (kind === "planner") return config.planner;
@@ -45,33 +42,17 @@ interface EntryPatch {
45
42
  thinking?: string;
46
43
  instructions?: string;
47
44
  timeoutMs?: number;
48
- /** `inherit` clears the fallback (its thinking level with it). */
49
- fallbackModel?: string;
50
- fallbackThinking?: string;
51
- }
52
-
53
- /** A settings entry with the fallback patch applied: `inherit` removes the fallback model and its thinking. */
54
- function withFallbackPatch<T extends { fallbackModel?: string; fallbackThinking?: string }>(entry: T): T {
55
- if (entry.fallbackModel !== INHERIT_MODEL) return entry;
56
- const { fallbackModel: _model, fallbackThinking: _thinking, ...rest } = entry;
57
- return rest as T;
58
45
  }
59
46
 
60
47
  /** Apply a patch to one entry in a config copy; scouts ignore thinking and instructions. */
61
48
  export function patchEntry(config: BotLobbyConfig, kind: SettingsKind, patch: EntryPatch): BotLobbyConfig {
62
49
  const next: BotLobbyConfig = { ...config, agents: { ...config.agents } };
63
- if (kind === "master") next.master = withFallbackPatch({ ...config.master, ...patch } as AgentModelConfig);
64
- else if (kind === "scout") {
65
- const fallbackModel = patch.fallbackModel ?? config.scout.fallbackModel;
66
- next.scout = withFallbackPatch({
67
- model: patch.model ?? config.scout.model,
68
- timeoutMs: patch.timeoutMs ?? config.scout.timeoutMs,
69
- ...(fallbackModel ? { fallbackModel } : {}),
70
- });
71
- } else if (kind === "researcher") next.researcher = withFallbackPatch({ ...config.researcher, ...patch } as AgentModelConfig);
72
- else if (kind === "quickfix") next.quickFix = withFallbackPatch({ ...config.quickFix, ...patch } as AgentModelConfig);
73
- else if (kind === "planner") next.planner = withFallbackPatch({ ...config.planner, ...patch } as AgentModelConfig);
74
- else next.agents[kind] = withFallbackPatch({ ...config.agents[kind], ...patch } as AgentModelConfig);
50
+ if (kind === "master") next.master = { ...config.master, ...patch } as AgentModelConfig;
51
+ else if (kind === "scout") next.scout = { model: patch.model ?? config.scout.model, timeoutMs: patch.timeoutMs ?? config.scout.timeoutMs };
52
+ else if (kind === "researcher") next.researcher = { ...config.researcher, ...patch } as AgentModelConfig;
53
+ else if (kind === "quickfix") next.quickFix = { ...config.quickFix, ...patch } as AgentModelConfig;
54
+ else if (kind === "planner") next.planner = { ...config.planner, ...patch } as AgentModelConfig;
55
+ else next.agents[kind] = { ...config.agents[kind], ...patch } as AgentModelConfig;
75
56
  return next;
76
57
  }
77
58
 
@@ -8,7 +8,6 @@ import {
8
8
  type PanelMember,
9
9
  THINKING_LEVELS,
10
10
  type BotLobbyConfig,
11
- type FallbackProfile,
12
11
  type ProfileResolver,
13
12
  type SubagentKind,
14
13
  type ThinkingLevelName,
@@ -87,25 +86,11 @@ export function createProfileResolver(config: BotLobbyConfig, options: ResolverO
87
86
  options.warn?.(message);
88
87
  }
89
88
  }
90
- return { ...profile, model, thinking: check.level, ...clampedFallback(profile.fallback, kindLabel(profile.kind as SubagentKind), options) };
89
+ return { ...profile, model, thinking: check.level };
91
90
  };
92
91
  }
93
92
 
94
- /** The fallback with its thinking level clamped to what the fallback model supports. */
95
- function clampedFallback(fallback: FallbackProfile | undefined, label: string, options: ResolverOptions): { fallback?: FallbackProfile } {
96
- if (!fallback) return {};
97
- const check = checkThinking(options.lookup(fallback.model), fallback.thinking);
98
- if (check.warning) {
99
- const message = `bot-lobby: ${label} fallback — ${check.warning}. Change it in /bot-lobby settings.`;
100
- if (!warned.has(message)) {
101
- warned.add(message);
102
- options.warn?.(message);
103
- }
104
- }
105
- return { fallback: { model: fallback.model, thinking: check.level } };
106
- }
107
-
108
- type RunProfile = { model?: string; thinking: string; timeoutMs: number; instructions?: string; fallback?: FallbackProfile };
93
+ type RunProfile = { model?: string; thinking: string; timeoutMs: number; instructions?: string };
109
94
 
110
95
  /** Unset models run on the session's; thinking is clamped to the model, with a one-time warning. */
111
96
  function resolveRunProfile(profile: RunProfile, label: string, options: ResolverOptions): RunProfile {
@@ -118,7 +103,7 @@ function resolveRunProfile(profile: RunProfile, label: string, options: Resolver
118
103
  options.warn?.(message);
119
104
  }
120
105
  }
121
- return { ...profile, model, thinking: check.level, ...clampedFallback(profile.fallback, label, options) };
106
+ return { ...profile, model, thinking: check.level };
122
107
  }
123
108
 
124
109
  /** A lobby agent's model, thinking and time limit. */
@@ -137,7 +122,7 @@ export function resolvePanelProfile(config: BotLobbyConfig, member: PanelMember,
137
122
  */
138
123
  export function resolveReviewProfile(config: BotLobbyConfig, options: ResolverOptions): RunProfile {
139
124
  const profile = agentProfile(config, "qa", "reviewer");
140
- return resolveRunProfile({ model: profile.model, thinking: profile.thinking, timeoutMs: profile.timeoutMs, ...(profile.instructions ? { instructions: profile.instructions } : {}), ...(profile.fallback ? { fallback: profile.fallback } : {}) }, kindLabel("qa"), options);
125
+ return resolveRunProfile({ model: profile.model, thinking: profile.thinking, timeoutMs: profile.timeoutMs, ...(profile.instructions ? { instructions: profile.instructions } : {}) }, kindLabel("qa"), options);
141
126
  }
142
127
 
143
128
  /** Every configured subagent whose thinking level its model does not support, for `/bot-lobby config`. */
@@ -31,18 +31,12 @@ export interface AgentModelConfig {
31
31
  instructions?: string;
32
32
  /** Time limit per run; falls back to `workflow.agentTimeoutMs`. */
33
33
  timeoutMs?: number;
34
- /** Model this agent switches to when its own model runs out of usage or is unavailable; unset or `inherit` = none. */
35
- fallbackModel?: ModelRef;
36
- /** Thinking level on the fallback model; unset uses this agent's own `thinking`. */
37
- fallbackThinking?: string;
38
34
  }
39
35
 
40
36
  /** Scouts pick a model and a time limit only; their thinking is fixed at `SCOUT_THINKING`. */
41
37
  export interface ScoutConfig {
42
38
  model: ModelRef;
43
39
  timeoutMs: number;
44
- /** Model scouts switch to when theirs runs out of usage or is unavailable (they think at `SCOUT_THINKING` on it too). */
45
- fallbackModel?: ModelRef;
46
40
  }
47
41
 
48
42
  /** Subagent kinds with their own settings entry. */
@@ -301,22 +295,18 @@ function positive(value: unknown): number | undefined {
301
295
 
302
296
  /** Merge one agent's override over its default; legacy `inherit` or unknown thinking falls back to the default level. */
303
297
  function normalizeAgent(base: AgentModelConfig, override: Partial<AgentModelConfig> | undefined): AgentModelConfig {
304
- const { fallbackModel: rawModel, fallbackThinking: rawThinking, ...merged } = { ...base, ...(override ?? {}) };
298
+ const merged = { ...base, ...(override ?? {}) };
305
299
  const thinking = isThinkingLevel(merged.thinking) ? merged.thinking : base.thinking;
306
300
  const timeoutMs = positive(merged.timeoutMs) ?? base.timeoutMs;
307
- const fallbackModel = typeof rawModel === "string" && rawModel.trim() && rawModel !== INHERIT_MODEL ? rawModel.trim() : undefined;
308
- const fallbackThinking = typeof rawThinking === "string" && isThinkingLevel(rawThinking) ? rawThinking : undefined;
309
- return { ...merged, thinking, ...(timeoutMs ? { timeoutMs } : {}), ...(fallbackModel ? { fallbackModel, ...(fallbackThinking ? { fallbackThinking } : {}) } : {}) };
301
+ return { model: merged.model, thinking, ...(typeof merged.instructions === "string" ? { instructions: merged.instructions } : {}), ...(timeoutMs ? { timeoutMs } : {}) };
310
302
  }
311
303
 
312
304
  /** Scouts keep a model and a time limit; any thinking value in the file is dropped. */
313
305
  function normalizeScout(override: Partial<ScoutConfig> | undefined): ScoutConfig {
314
306
  const base = DEFAULT_CONFIG.scout;
315
- const fallback = override?.fallbackModel;
316
307
  return {
317
308
  model: typeof override?.model === "string" && override.model.trim() ? override.model : base.model,
318
309
  timeoutMs: positive(override?.timeoutMs) ?? base.timeoutMs,
319
- ...(typeof fallback === "string" && fallback.trim() && fallback !== INHERIT_MODEL ? { fallbackModel: fallback.trim() } : {}),
320
310
  };
321
311
  }
322
312
 
@@ -441,22 +431,6 @@ export interface AgentProfile {
441
431
  thinking: string;
442
432
  timeoutMs: number;
443
433
  instructions?: string;
444
- /** Where the run goes when its model runs out of usage or is unavailable; absent = it just fails. */
445
- fallback?: FallbackProfile;
446
- }
447
-
448
- /** The fallback model and the thinking level to run it at. */
449
- export interface FallbackProfile {
450
- model: string;
451
- thinking: string;
452
- }
453
-
454
- /** The fallback of a settings entry: its model and thinking (the entry's own level when none is set). */
455
- export function fallbackOf(entry: { fallbackModel?: string; fallbackThinking?: string; thinking?: string }): FallbackProfile | undefined {
456
- const model = modelOf(entry.fallbackModel ?? INHERIT_MODEL);
457
- if (!model) return undefined;
458
- const own = entry.thinking && isThinkingLevel(entry.thinking) ? entry.thinking : DEFAULT_THINKING;
459
- return { model, thinking: entry.fallbackThinking && isThinkingLevel(entry.fallbackThinking) ? entry.fallbackThinking : own };
460
434
  }
461
435
 
462
436
  /** The settings entry a domain/role run draws from. */
@@ -481,12 +455,10 @@ export function agentProfile(config: BotLobbyConfig, domain: Domain, role: Role)
481
455
  const instructions = config.agents[domain].instructions;
482
456
  const fallback = config.workflow.agentTimeoutMs;
483
457
  if (kind === "scout") {
484
- const scoutFallback = fallbackOf({ fallbackModel: config.scout.fallbackModel, thinking: SCOUT_THINKING });
485
- return { kind, model: modelOf(config.scout.model), thinking: SCOUT_THINKING, timeoutMs: config.scout.timeoutMs || fallback, instructions, ...(scoutFallback ? { fallback: scoutFallback } : {}) };
458
+ return { kind, model: modelOf(config.scout.model), thinking: SCOUT_THINKING, timeoutMs: config.scout.timeoutMs || fallback, instructions };
486
459
  }
487
460
  const entry = kind === "researcher" ? config.researcher : config.agents[kind];
488
- const entryFallback = fallbackOf(entry);
489
- return { kind, model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? fallback, instructions, ...(entryFallback ? { fallback: entryFallback } : {}) };
461
+ return { kind, model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? fallback, instructions };
490
462
  }
491
463
 
492
464
  /** The settings entry of a lobby agent (quick fix or planner). */
@@ -495,21 +467,19 @@ export function lobbyAgentConfig(config: BotLobbyConfig, kind: LobbyAgentKind):
495
467
  }
496
468
 
497
469
  /** Profile for a lobby agent run, from settings alone; `model` is undefined while unset. */
498
- export function lobbyAgentProfile(config: BotLobbyConfig, kind: LobbyAgentKind): { model?: string; thinking: string; timeoutMs: number; instructions?: string; fallback?: FallbackProfile } {
470
+ export function lobbyAgentProfile(config: BotLobbyConfig, kind: LobbyAgentKind): { model?: string; thinking: string; timeoutMs: number; instructions?: string } {
499
471
  const entry = lobbyAgentConfig(config, kind);
500
- const fallback = fallbackOf(entry);
501
- return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? config.workflow.agentTimeoutMs, instructions: entry.instructions, ...(fallback ? { fallback } : {}) };
472
+ return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? config.workflow.agentTimeoutMs, instructions: entry.instructions };
502
473
  }
503
474
 
504
475
  /**
505
476
  * A planning panel seat's profile: the domain's (or the researcher's) model,
506
477
  * thinking and custom instructions, bounded by the planner's per-turn limit.
507
478
  */
508
- export function panelMemberProfile(config: BotLobbyConfig, member: PanelMember): { model?: string; thinking: string; timeoutMs: number; instructions?: string; fallback?: FallbackProfile } {
479
+ export function panelMemberProfile(config: BotLobbyConfig, member: PanelMember): { model?: string; thinking: string; timeoutMs: number; instructions?: string } {
509
480
  const entry = member === "researcher" ? config.researcher : config.agents[member];
510
481
  const timeoutMs = config.planner.timeoutMs ?? config.workflow.agentTimeoutMs;
511
- const fallback = fallbackOf(entry);
512
- return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs, instructions: entry.instructions, ...(fallback ? { fallback } : {}) };
482
+ return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs, instructions: entry.instructions };
513
483
  }
514
484
 
515
485
  /** Resolves the model, thinking and time limit one subagent run uses. */
@@ -139,8 +139,6 @@ export interface AgentRun {
139
139
  wrappedUp?: boolean;
140
140
  /** File this worker is queued for at the file desk. */
141
141
  waitingFor?: string;
142
- /** The run switched to its fallback model because this one ran out of usage or was unavailable. */
143
- fellBackFrom?: string;
144
142
  /** The classifier routed this run down: the configured profile it came from (`p/big · medium`). */
145
143
  routedFrom?: string;
146
144
  /** Why and where it was routed (`trivial 0.88: p/big · medium → p/cheap · low`). */
@@ -1,4 +1,5 @@
1
1
  import type { Domain, Role } from "./agent.ts";
2
+ import type { Delivery } from "../delivery/types.ts";
2
3
 
3
4
  export const TASK_STATES = [
4
5
  "created",
@@ -171,7 +172,18 @@ export interface TaskGit {
171
172
  path?: string;
172
173
  }
173
174
 
175
+ export interface PhaseTiming {
176
+ phase: TaskState;
177
+ elapsedMs: number;
178
+ runningSince?: string;
179
+ blockingRequestIds: string[];
180
+ }
181
+
174
182
  export interface Task {
183
+ /** Canonical waits, including legacy tasks without a reliable clock. */
184
+ blockingRequestIds?: string[];
185
+ phaseTiming?: PhaseTiming;
186
+ delivery?: Delivery;
175
187
  id: string;
176
188
  title: string;
177
189
  /** The user's original request, kept in full while `title` stays a short label. */
@@ -226,6 +238,8 @@ export function createTask(
226
238
  title,
227
239
  request,
228
240
  state: "created",
241
+ blockingRequestIds: [],
242
+ phaseTiming: { phase: "created", elapsedMs: 0, runningSince: now, blockingRequestIds: [] },
229
243
  domains: [],
230
244
  amendments: [],
231
245
  paused: false,
@@ -0,0 +1,23 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { randomUUID } from "node:crypto";
3
+ import { loadTask, saveTask } from "./persistence.ts";
4
+ import { blockPhaseTiming, resolvePhaseTiming } from "./phase-timing.ts";
5
+
6
+ export interface TaskRequestIdentity { root: string; configDir: string; taskId: string; sessionId?: string }
7
+ export const blockingRequestContext = new AsyncLocalStorage<TaskRequestIdentity & { requestId: string }>();
8
+
9
+ /** Always mutate a fresh authoritative task, never the workflow's long-lived copy. */
10
+ export function updateBlockingRequest(identity: TaskRequestIdentity, id: string, pending: boolean): void {
11
+ const task = loadTask(identity.root, identity.configDir, identity.taskId);
12
+ if (!task || (identity.sessionId && task.ownerSessionId !== identity.sessionId)) return;
13
+ if (pending) blockPhaseTiming(task, id);
14
+ else resolvePhaseTiming(task, id);
15
+ saveTask(identity.root, identity.configDir, task);
16
+ }
17
+
18
+ export async function withBlockingRequest<T>(identity: TaskRequestIdentity, run: () => Promise<T>): Promise<T> {
19
+ const id = `request:${randomUUID()}`;
20
+ updateBlockingRequest(identity, id, true);
21
+ try { return await blockingRequestContext.run({ ...identity, requestId: id }, run); }
22
+ finally { updateBlockingRequest(identity, id, false); }
23
+ }
@@ -16,6 +16,13 @@ import {
16
16
  import { DEFAULT_KNOWLEDGE_CONTENT, ensureFile, readFileOr, writeFileEnsured } from "../knowledge/store.ts";
17
17
  import { forgetCached, forgetCachedUnder, readJsonCached } from "./file-cache.ts";
18
18
  import { taskName } from "../text.ts";
19
+ import { mergePhaseTiming, timingBaseline, type TimingBaseline } from "./timing-merge.ts";
20
+
21
+ const timingBaselines = new WeakMap<Task, TimingBaseline>();
22
+ function rememberTiming(task: Task): Task {
23
+ timingBaselines.set(task, timingBaseline(task));
24
+ return task;
25
+ }
19
26
 
20
27
  /** Idempotently create the full knowledge + tasks layout with seed files. */
21
28
  export function ensureProjectStructure(root: string, configDir: string): void {
@@ -40,6 +47,7 @@ export function createTaskDir(root: string, configDir: string, task: Task): void
40
47
  mkdirSync(dir, { recursive: true });
41
48
  forgetCached(join(dir, "state.json"));
42
49
  writeFileEnsured(join(dir, "state.json"), JSON.stringify(task, null, 2));
50
+ rememberTiming(task);
43
51
  ensureFile(join(dir, "proposal.md"), "");
44
52
  ensureFile(join(dir, "plan.md"), "");
45
53
  for (const domain of ["designer", "backend", "qa"] as const) {
@@ -80,9 +88,12 @@ export function readTaskArtifact(
80
88
  }
81
89
 
82
90
  export function saveTask(root: string, configDir: string, task: Task): void {
91
+ const latest = loadTask(root, configDir, task.id);
92
+ if (latest) mergePhaseTiming(task, timingBaselines.get(task), latest);
83
93
  const path = join(taskDir(dataRoot(root, configDir), task.id), "state.json");
84
94
  forgetCached(path);
85
95
  writeFileEnsured(path, JSON.stringify(task, null, 2));
96
+ rememberTiming(task);
86
97
  }
87
98
 
88
99
  /** Read a task state: the bot-lobby copy wins, else the newest pre-rename copy. */
@@ -91,7 +102,7 @@ export function loadTask(root: string, configDir: string, taskId: string): Task
91
102
  const path = join(taskDir(dr, taskId), "state.json");
92
103
  if (!existsSync(path)) continue;
93
104
  try {
94
- return JSON.parse(readFileSync(path, "utf8")) as Task;
105
+ return rememberTiming(JSON.parse(readFileSync(path, "utf8")) as Task);
95
106
  } catch {
96
107
  // A bot-lobby state.json that exists but cannot be parsed is surfaced, not shadowed.
97
108
  return undefined;
@@ -0,0 +1,53 @@
1
+ import { TERMINAL_STATES, type Task, type TaskState } from "../schemas/task.ts";
2
+
3
+ export interface TimingProjection {
4
+ phase: string;
5
+ elapsedMs: number;
6
+ runningSince?: string;
7
+ waiting: boolean;
8
+ serverNow: string;
9
+ }
10
+
11
+ function elapsed(task: Task, now: string): number {
12
+ const timing = task.phaseTiming;
13
+ if (!timing) return 0;
14
+ const delta = timing.runningSince ? Date.parse(now) - Date.parse(timing.runningSince) : 0;
15
+ return timing.elapsedMs + (Number.isFinite(delta) ? Math.max(0, delta) : 0);
16
+ }
17
+
18
+ export function resetPhaseTiming(task: Task, phase: TaskState, now: string): void {
19
+ const blockingRequestIds = [...(task.blockingRequestIds ?? task.phaseTiming?.blockingRequestIds ?? [])];
20
+ task.blockingRequestIds = [...blockingRequestIds];
21
+ task.phaseTiming = { phase, elapsedMs: 0, blockingRequestIds };
22
+ if (!blockingRequestIds.length && !TERMINAL_STATES.includes(phase)) task.phaseTiming.runningSince = now;
23
+ }
24
+
25
+ /** Legacy tasks remain unavailable until a real phase transition. */
26
+ export function blockPhaseTiming(task: Task, id: string, now = new Date().toISOString()): void {
27
+ const ids = task.blockingRequestIds ?? task.phaseTiming?.blockingRequestIds ?? [];
28
+ if (ids.includes(id)) return;
29
+ task.blockingRequestIds = [...ids, id];
30
+ const timing = task.phaseTiming;
31
+ if (!timing) return;
32
+ timing.elapsedMs = elapsed(task, now);
33
+ delete timing.runningSince;
34
+ timing.blockingRequestIds = [...task.blockingRequestIds];
35
+ }
36
+
37
+ export function resolvePhaseTiming(task: Task, id: string, now = new Date().toISOString()): void {
38
+ const ids = task.blockingRequestIds ?? task.phaseTiming?.blockingRequestIds ?? [];
39
+ if (!ids.includes(id)) return;
40
+ task.blockingRequestIds = ids.filter((entry) => entry !== id);
41
+ const timing = task.phaseTiming;
42
+ if (!timing) return;
43
+ timing.blockingRequestIds = [...task.blockingRequestIds];
44
+ if (!timing.blockingRequestIds.length && !TERMINAL_STATES.includes(task.state)) timing.runningSince = now;
45
+ }
46
+
47
+ /** Baseline plus anchor: clients advance only the anchor, not server wall time. */
48
+ export function projectPhaseTiming(task: Task, serverNow = new Date().toISOString()): TimingProjection | undefined {
49
+ const timing = task.phaseTiming;
50
+ if (!timing) return undefined;
51
+ return { phase: timing.phase, elapsedMs: timing.elapsedMs, runningSince: timing.runningSince,
52
+ waiting: timing.blockingRequestIds.length > 0, serverNow };
53
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Task, TaskState } from "../schemas/task.ts";
2
2
  import { assertTransition } from "../workflow/transitions.ts";
3
+ import { resetPhaseTiming } from "./phase-timing.ts";
3
4
 
4
5
  /**
5
6
  * Single mutation point for task state. Every state change flows through here
@@ -16,6 +17,7 @@ export function onTransition(fn: TransitionListener): void {
16
17
 
17
18
  export function transition(task: Task, to: TaskState, now = new Date().toISOString()): void {
18
19
  assertTransition(task.state, to);
20
+ resetPhaseTiming(task, to, now);
19
21
  task.state = to;
20
22
  task.updatedAt = now;
21
23
  listener?.(task, to);
@@ -0,0 +1,25 @@
1
+ import type { PhaseTiming, Task } from "../schemas/task.ts";
2
+ import { blockPhaseTiming, resetPhaseTiming, resolvePhaseTiming } from "./phase-timing.ts";
3
+
4
+ export interface TimingBaseline { timing?: PhaseTiming; ids: string[] }
5
+ export function timingBaseline(task: Task): TimingBaseline {
6
+ return { timing: task.phaseTiming ? structuredClone(task.phaseTiming) : undefined,
7
+ ids: [...(task.blockingRequestIds ?? task.phaseTiming?.blockingRequestIds ?? [])] };
8
+ }
9
+
10
+ /** Apply only this copy's wait deltas to the authoritative registry and clock. */
11
+ export function mergePhaseTiming(task: Task, baseline: TimingBaseline | undefined, latest: Task): void {
12
+ const incoming = timingBaseline(task);
13
+ const oldIds = baseline?.ids ?? incoming.ids;
14
+ task.blockingRequestIds = [...(latest.blockingRequestIds ?? latest.phaseTiming?.blockingRequestIds ?? [])];
15
+ if (JSON.stringify(baseline?.timing) !== JSON.stringify(latest.phaseTiming)) {
16
+ task.phaseTiming = latest.phaseTiming ? structuredClone(latest.phaseTiming) : undefined;
17
+ }
18
+ const now = new Date().toISOString();
19
+ for (const id of oldIds.filter((id) => !incoming.ids.includes(id))) resolvePhaseTiming(task, id, now);
20
+ for (const id of incoming.ids.filter((id) => !oldIds.includes(id))) blockPhaseTiming(task, id, now);
21
+ if (task.phaseTiming) task.phaseTiming.blockingRequestIds = [...task.blockingRequestIds];
22
+ if (task.state !== latest.state || (task.phaseTiming && task.state !== task.phaseTiming.phase)) {
23
+ resetPhaseTiming(task, task.state, task.updatedAt);
24
+ }
25
+ }
@@ -15,7 +15,7 @@ import { lobbyAbort, lobbyHistory, lobbySend, lobbySnapshot } from "./lobby.ts";
15
15
  import { promptsAnswer, promptsDismiss, promptsList } from "./prompts.ts";
16
16
  import { plannerAnswer, plannerCommentLine, plannerGet, plannerNew, plannerRetry, plannerSend, plannerEditMessage, plannerToggleSeat, plannerSave } from "./planner.ts";
17
17
  import { quickfixCancel, quickfixList, quickfixMovedToTask, quickfixRunAnyway, quickfixSubmit } from "./quickfix.ts";
18
- import { tasksArchive, tasksArchived, tasksAuto, tasksComment, tasksComments, tasksDelete, tasksEditComment, tasksGet, tasksList, tasksMessage, tasksRestore } from "./tasks.ts";
18
+ import { tasksArchive, tasksArchived, tasksAuto, tasksComment, tasksComments, tasksDelete, tasksEditComment, tasksGet, tasksList, tasksMessage, tasksRestore, tasksOpen, tasksDeliveryReview, tasksDeliveryDefer, tasksDeliver } from "./tasks.ts";
19
19
  import { metricsGet } from "./metrics.ts";
20
20
  import { knowledgeAdd, knowledgeComment, knowledgeEdit, knowledgeFiles, knowledgeOpen, knowledgeRemove, knowledgeReplaceFile, knowledgeUnnote } from "./knowledge.ts";
21
21
  import { excalidrawAdd, excalidrawCheck, excalidrawCreate, excalidrawList, excalidrawRemove, excalidrawRename, excalidrawReveal, excalidrawToggleAgent, excalidrawToggleAll, excalidrawToggleContribute } from "./excalidraw.ts";
@@ -124,6 +124,22 @@ function buildRoutes(): Record<string, Route> {
124
124
  schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
125
125
  run: (body, ctx) => tasksGet(body as { taskId: string }, ctx),
126
126
  };
127
+ routes["tasks.open"] = {
128
+ schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
129
+ run: (body, ctx) => tasksOpen(body as { taskId: string }, ctx),
130
+ };
131
+ routes["tasks.deliveryReview"] = {
132
+ schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
133
+ run: (body, ctx) => tasksDeliveryReview(body as { taskId: string }, ctx),
134
+ };
135
+ routes["tasks.deliver"] = {
136
+ schema: Type.Object({ taskId: TaskId, reviewId: Type.String({ minLength: 1, maxLength: 200 }), action: Type.Union([Type.Literal("create_pr"), Type.Literal("merge_main")]), confirmMain: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
137
+ run: (body, ctx) => tasksDeliver(body as unknown as Parameters<typeof tasksDeliver>[0], ctx),
138
+ };
139
+ routes["tasks.deliveryDefer"] = {
140
+ schema: Type.Object({ taskId: TaskId, reviewId: Type.String({ minLength: 1, maxLength: 200 }) }, { additionalProperties: false }),
141
+ run: (body, ctx) => tasksDeliveryDefer(body as { taskId: string; reviewId: string }, ctx),
142
+ };
127
143
  routes["tasks.comments"] = {
128
144
  schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
129
145
  run: (body, ctx) => tasksComments(body as { taskId: string }, ctx),
@@ -15,6 +15,37 @@ import { withAttachments } from "../uploads.ts";
15
15
  import { fail } from "./index.ts";
16
16
  import { taskSteps } from "./plan-facts.ts";
17
17
  import type { TaskSection } from "../../lobby/task-rows.ts";
18
+ import { projectPhaseTiming } from "../../state/phase-timing.ts";
19
+ import type { DeliveryRequest } from "../../delivery/operations.ts";
20
+
21
+ function deliveryTask(body: { taskId: string }, ctx: ApiContext): Task {
22
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,199}$/.test(body.taskId)) fail(400, "bad_request", "invalid task ID");
23
+ const task = ctx.service.tasks().find((entry) => entry.id === body.taskId);
24
+ if (!task) fail(404, "not_found", "task not found in this project");
25
+ if (task.state !== "completed" || !task.delivery) fail(400, "bad_request", "this task has no completed-work delivery review");
26
+ return task;
27
+ }
28
+ export async function tasksDeliveryReview(body: { taskId: string }, ctx: ApiContext) {
29
+ deliveryTask(body, ctx);
30
+ if (!ctx.service.deliveryReview) fail(400, "bad_request", "delivery review is unavailable");
31
+ try { return { delivery: await ctx.service.deliveryReview(body.taskId) }; }
32
+ catch (error) { return fail(400, "bad_request", (error as Error).message); }
33
+ }
34
+ export async function tasksDeliver(body: { taskId: string } & DeliveryRequest, ctx: ApiContext) {
35
+ const task = deliveryTask(body, ctx);
36
+ if (task.delivery!.reviewId !== body.reviewId) fail(409, "conflict", "review changed; refresh before delivery");
37
+ if (body.action === "merge_main" && body.confirmMain !== true) fail(400, "bad_request", "confirm that direct merge updates and pushes main");
38
+ if (!ctx.service.deliveryDeliver) fail(400, "bad_request", "delivery is unavailable");
39
+ try { return { delivery: await ctx.service.deliveryDeliver(body.taskId, body) }; }
40
+ catch (error) { return fail(409, "conflict", (error as Error).message); }
41
+ }
42
+ export function tasksDeliveryDefer(body: { taskId: string; reviewId: string }, ctx: ApiContext) {
43
+ const task = deliveryTask(body, ctx);
44
+ if (task.delivery!.reviewId !== body.reviewId) fail(409, "conflict", "review changed; refresh before deferring");
45
+ if (!ctx.service.deliveryDefer) fail(400, "bad_request", "delivery review is unavailable");
46
+ try { return { delivery: ctx.service.deliveryDefer(body.taskId, body.reviewId) }; }
47
+ catch (error) { return fail(400, "bad_request", (error as Error).message); }
48
+ }
18
49
 
19
50
  interface RowContext {
20
51
  names: ReadonlyMap<string, string>;
@@ -62,6 +93,8 @@ function rowOf(task: Task, section: TaskSection, ctx: RowContext, sessionId: str
62
93
  title: task.title,
63
94
  section,
64
95
  status: task.state,
96
+ timing: projectPhaseTiming(task, new Date(now).toISOString()),
97
+ ...(task.delivery ? { delivery: { status: task.delivery.status, reviewId: task.delivery.reviewId } } : {}),
65
98
  ...(task.paused && section !== "recent" ? { paused: true } : {}),
66
99
  check: checkOf(task),
67
100
  ...(progress ? { progress } : {}),
@@ -94,6 +127,8 @@ function buildRows(tasks: readonly Task[], plans: readonly PlannedTask[], sessio
94
127
  title: task.title,
95
128
  section: "archived",
96
129
  status: task.state,
130
+ timing: projectPhaseTiming(task, new Date(now).toISOString()),
131
+ ...(task.delivery ? { delivery: { status: task.delivery.status, reviewId: task.delivery.reviewId } } : {}),
97
132
  check: checkOf(task),
98
133
  age: ago(now - Date.parse(task.archivedAt ?? task.updatedAt)),
99
134
  })),
@@ -125,6 +160,8 @@ export function tasksArchived(ctx: ApiContext): { rows: TaskRow[] } {
125
160
  title: task.title,
126
161
  section: "archived",
127
162
  status: task.state,
163
+ timing: projectPhaseTiming(task),
164
+ ...(task.delivery ? { delivery: { status: task.delivery.status, reviewId: task.delivery.reviewId } } : {}),
128
165
  check: checkOf(task),
129
166
  age: ago(Date.now() - Date.parse(task.archivedAt ?? task.updatedAt)),
130
167
  }));
@@ -190,6 +227,19 @@ export function tasksMessage(body: { taskId: string; text: string; attachments?:
190
227
  return { notice: ctx.service.sendToTask(body.taskId, withAttachments(body.text, body.attachments, body.taskId, ctx.service.projectRoot?.())) };
191
228
  }
192
229
 
230
+ /** Select existing owner chat metadata only; never start, claim or switch a process. */
231
+ export function tasksOpen(body: { taskId: string }, ctx: ApiContext): { sessionId?: string; key?: string; notice?: string } {
232
+ const task = ctx.service.tasks().find((entry) => entry.id === body.taskId);
233
+ if (!task) fail(404, "not_found", `no task ${body.taskId} in this project`);
234
+ const sessionId = task.ownerSessionId;
235
+ if (!sessionId) return { notice: "This task has no owning session. No replacement session was created." };
236
+ if (sessionId === ctx.service.sessionId()) return { sessionId };
237
+ const background = ctx.service.sessions().find((entry) => entry.sessionId === sessionId && entry.alive);
238
+ if (background) return { sessionId, key: background.key };
239
+ if (ctx.service.liveSessions().some((entry) => entry.sessionId === sessionId)) return { sessionId };
240
+ return { notice: "The task's owning session has ended or is unavailable. No replacement session was created." };
241
+ }
242
+
193
243
  /** How many recent runs the detail lists (the terminal's count). */
194
244
  const DETAIL_RUNS = 6;
195
245
 
@@ -206,6 +256,8 @@ export function tasksGet(body: { taskId: string }, ctx: ApiContext): TaskDetail
206
256
  const request = taskRequest(found);
207
257
  const now = Date.now();
208
258
  return {
259
+ timing: projectPhaseTiming(found, new Date(now).toISOString()),
260
+ ...(found.delivery ? { delivery: found.delivery } : {}),
209
261
  ...(request !== found.title ? { request } : {}),
210
262
  ...(!found.plan && found.proposal ? { proposal: found.proposal } : {}),
211
263
  ...(found.plan ? { plan: found.plan } : {}),
@@ -3,6 +3,8 @@
3
3
  * The page imports this file with `import type` only, so nothing from the
4
4
  * server is ever bundled into it.
5
5
  */
6
+ import type { Delivery, DeliveryAction } from "../delivery/types.ts";
7
+ import type { TimingProjection } from "../state/phase-timing.ts";
6
8
  import type { LobbyTopic } from "../lobby/topics.ts";
7
9
  import type { WebPrompt } from "../lobby/prompt-hub.ts";
8
10
  import type { DialogAnswer, SessionDialog } from "../lobby/sessions.ts";
@@ -338,6 +340,8 @@ export interface ExcalidrawCheck {
338
340
 
339
341
  /** `tasks.get`: one task read whole, in the terminal's wording. */
340
342
  export interface TaskDetail {
343
+ timing?: TimingProjection;
344
+ delivery?: Delivery;
341
345
  /** The full request, when it says more than the title. */
342
346
  request?: string;
343
347
  /** The oracle's proposal, while the task has no approved plan yet. */
@@ -519,6 +523,10 @@ export interface Api {
519
523
  "tasks.auto": { request: { taskId: string; on: boolean }; result: { notice: string; on: boolean } };
520
524
  "tasks.message": { request: { taskId: string; text: string; attachments?: string[] }; result: { notice: string } };
521
525
  "tasks.get": { request: { taskId: string }; result: TaskDetail };
526
+ "tasks.open": { request: { taskId: string }; result: { sessionId?: string; key?: string; notice?: string } };
527
+ "tasks.deliveryReview": { request: { taskId: string }; result: { delivery: Delivery } };
528
+ "tasks.deliveryDefer": { request: { taskId: string; reviewId: string }; result: { delivery: Delivery } };
529
+ "tasks.deliver": { request: { taskId: string; reviewId: string; action: DeliveryAction; confirmMain?: boolean }; result: { delivery: Delivery } };
522
530
  "plans.get": { request: { planId: string }; result: PlanDetail };
523
531
  "plans.start": { request: { planId: string; where: "here" | "session"; auto?: boolean }; result: { notice: string; key?: string } };
524
532
  "plans.discard": { request: { planId: string }; result: { notice: string } };
@@ -1,5 +1,6 @@
1
1
  import type { Approval, ApprovalKind, Task } from "../schemas/task.ts";
2
2
  import type { Domain } from "../schemas/agent.ts";
3
+ import { blockPhaseTiming, resolvePhaseTiming } from "../state/phase-timing.ts";
3
4
 
4
5
  /** Record a Worker-requested exception so it blocks further work until resolved. */
5
6
  export function requestApproval(
@@ -11,6 +12,7 @@ export function requestApproval(
11
12
  ): Approval {
12
13
  const approval: Approval = { id: `APR-${task.approvals.length + 1}`, kind, domain, detail, status: "pending", createdAt: now };
13
14
  task.approvals.push(approval);
15
+ blockPhaseTiming(task, `approval:${approval.id}`, now);
14
16
  return approval;
15
17
  }
16
18
 
@@ -30,6 +32,7 @@ export function resolveApproval(
30
32
  if (!approval) return undefined;
31
33
  approval.status = status;
32
34
  approval.note = note;
35
+ resolvePhaseTiming(task, `approval:${approval.id}`);
33
36
  return approval;
34
37
  }
35
38