vibe-coding-master 0.3.2 → 0.3.4

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.
@@ -23,7 +23,10 @@ export function createClaudeAdapter(runner) {
23
23
  args.push(resume ? "--resume" : "--session-id", claudeSessionId);
24
24
  }
25
25
  args.push("--model", model);
26
- if (effort !== "default") {
26
+ if (effort === "ultracode") {
27
+ args.push("--settings", JSON.stringify({ ultracode: true }));
28
+ }
29
+ else if (effort !== "default") {
27
30
  args.push("--effort", effort);
28
31
  }
29
32
  if (permissionMode === "bypassPermissions") {
@@ -7,6 +7,7 @@ export function registerRoundRoutes(app, deps) {
7
7
  const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
8
8
  const taskRepoRoot = getTaskRuntimeRepoRoot(task);
9
9
  return deps.roundService.getSessionRoundState({
10
+ repoRoot: project.repoRoot,
10
11
  stateRepoRoot: taskRepoRoot,
11
12
  stateRoot: config.stateRoot,
12
13
  taskSlug: request.params.taskSlug
@@ -33,16 +33,22 @@ export function createNodePtyTerminalRuntime(deps) {
33
33
  startedAt: now(),
34
34
  exitCode: null
35
35
  };
36
+ const logWriter = createTerminalLogWriter(deps.fs, input.logPath, deps.onLogWriteError ?? defaultLogWriteErrorHandler);
36
37
  const entry = {
37
38
  input,
38
39
  session,
39
40
  process: child,
40
- listeners: new Set()
41
+ listeners: new Set(),
42
+ logWriter,
43
+ disposed: false
41
44
  };
42
45
  entries.set(session.id, entry);
43
46
  child.onData((data) => {
47
+ if (entry.disposed) {
48
+ return;
49
+ }
44
50
  entry.session.lastOutputAt = now();
45
- void deps.fs.appendText(input.logPath, data);
51
+ entry.logWriter.append(data);
46
52
  emit(entry, {
47
53
  sessionId: session.id,
48
54
  taskSlug: input.taskSlug,
@@ -52,8 +58,13 @@ export function createNodePtyTerminalRuntime(deps) {
52
58
  });
53
59
  });
54
60
  child.onExit(({ exitCode }) => {
61
+ if (entry.disposed) {
62
+ return;
63
+ }
64
+ entry.disposed = true;
55
65
  entry.session.status = exitCode === 0 ? "exited" : "crashed";
56
66
  entry.session.exitCode = exitCode;
67
+ void entry.logWriter.close();
57
68
  emit(entry, {
58
69
  sessionId: session.id,
59
70
  taskSlug: input.taskSlug,
@@ -102,12 +113,16 @@ export function createNodePtyTerminalRuntime(deps) {
102
113
  },
103
114
  async stop(sessionId) {
104
115
  const entry = getEntry(entries, sessionId);
116
+ entry.disposed = true;
105
117
  entry.session.status = "exited";
106
118
  entry.process.kill();
119
+ await entry.logWriter.close();
107
120
  },
108
121
  async restart(sessionId) {
109
122
  const entry = getEntry(entries, sessionId);
123
+ entry.disposed = true;
110
124
  entry.process.kill();
125
+ await entry.logWriter.close();
111
126
  return create(entry.input, sessionId);
112
127
  },
113
128
  subscribe(sessionId, listener, options = {}) {
@@ -139,12 +154,40 @@ export function createNodePtyTerminalRuntime(deps) {
139
154
  }
140
155
  };
141
156
  }
157
+ export function createTerminalLogWriter(fs, logPath, onError) {
158
+ let closed = false;
159
+ let pending = Promise.resolve();
160
+ return {
161
+ append(data) {
162
+ if (closed || !data) {
163
+ return;
164
+ }
165
+ pending = pending
166
+ .then(() => fs.appendText(logPath, data))
167
+ .catch((error) => {
168
+ try {
169
+ onError(error, logPath);
170
+ }
171
+ catch {
172
+ // Logging must never break terminal output delivery or future log writes.
173
+ }
174
+ });
175
+ },
176
+ async close() {
177
+ closed = true;
178
+ await pending.catch(() => undefined);
179
+ }
180
+ };
181
+ }
142
182
  async function readTerminalReplayText(fs, logPath) {
143
183
  const data = fs.readTextTail
144
184
  ? await fs.readTextTail(logPath, TERMINAL_REPLAY_TAIL_LIMIT_BYTES)
145
185
  : tailTerminalReplay(await fs.readText(logPath));
146
186
  return tailTerminalReplay(data);
147
187
  }
188
+ function defaultLogWriteErrorHandler(error, logPath) {
189
+ console.warn(`[VCM] Failed to append terminal log: ${logPath}`, error);
190
+ }
148
191
  export function tailTerminalReplay(data, limitBytes = TERMINAL_REPLAY_TAIL_LIMIT_BYTES) {
149
192
  if (limitBytes <= 0 || Buffer.byteLength(data, "utf8") <= limitBytes) {
150
193
  return data;
@@ -184,19 +184,22 @@ export function createDefaultServerDeps(options = {}) {
184
184
  sessionService,
185
185
  taskService
186
186
  });
187
+ const roundService = createRoundService({
188
+ fs,
189
+ sessionService,
190
+ onSessionStatusChange: async ({ repoRoot, taskSlug, status }) => {
191
+ await taskService.updateTaskStatus(repoRoot, taskSlug, status);
192
+ }
193
+ });
187
194
  const codexReviewService = createCodexReviewService({
188
195
  fs,
189
196
  runner,
190
197
  runtime,
191
198
  projectService,
192
199
  taskService,
193
- sessionService
194
- });
195
- const roundService = createRoundService({
196
- fs,
197
- onSessionStatusChange: async ({ repoRoot, taskSlug, status }) => {
198
- await taskService.updateTaskStatus(repoRoot, taskSlug, status);
199
- }
200
+ appSettings,
201
+ sessionService,
202
+ roundService
200
203
  });
201
204
  const transcripts = createClaudeTranscriptService();
202
205
  const translationService = createTranslationService({
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { homedir } from "node:os";
3
3
  import { createHash } from "node:crypto";
4
4
  import { VCM_ROLE_NAMES } from "../../shared/constants.js";
5
+ import { CODEX_REVIEW_GATES } from "../../shared/types/codex-review.js";
5
6
  import { createDefaultLaunchTemplate } from "../../shared/types/app-settings.js";
6
7
  import { CLAUDE_MODEL_OPTIONS, SESSION_EFFORT_OPTIONS } from "../../shared/types/session.js";
7
8
  const MAX_RECENT_REPOSITORIES = 5;
@@ -129,6 +130,32 @@ export function createAppSettingsService(deps) {
129
130
  });
130
131
  return config;
131
132
  },
133
+ async getCodexReviewSettings() {
134
+ const settings = await loadSettings();
135
+ const requiredGates = normalizeCodexReviewGates(settings.codexReview?.requiredGates);
136
+ return {
137
+ enabled: requiredGates.length > 0,
138
+ requiredGates
139
+ };
140
+ },
141
+ async updateCodexReviewSettings(_repoRoot, _taskSlug, requiredGates) {
142
+ const current = await loadSettings();
143
+ const normalizedRequiredGates = normalizeCodexReviewGates(requiredGates);
144
+ const timestamp = new Date().toISOString();
145
+ const nextCodexReview = {
146
+ version: 1,
147
+ requiredGates: normalizedRequiredGates,
148
+ updatedAt: timestamp
149
+ };
150
+ await saveSettings({
151
+ ...current,
152
+ codexReview: normalizeCodexReviewSettingsState(nextCodexReview)
153
+ });
154
+ return {
155
+ enabled: normalizedRequiredGates.length > 0,
156
+ requiredGates: normalizedRequiredGates
157
+ };
158
+ },
132
159
  getSettingsPath() {
133
160
  return settingsPath;
134
161
  },
@@ -172,13 +199,41 @@ function normalizeProjectIndexFile(input) {
172
199
  projects
173
200
  };
174
201
  }
175
- function normalizeSettingsFile(input) {
202
+ function normalizeCodexReviewSettingsState(input) {
203
+ if (!isObject(input)) {
204
+ return undefined;
205
+ }
176
206
  return {
207
+ version: 1,
208
+ requiredGates: normalizeCodexReviewGates(input.requiredGates),
209
+ updatedAt: typeof input.updatedAt === "string" ? input.updatedAt : new Date(0).toISOString()
210
+ };
211
+ }
212
+ function normalizeCodexReviewGates(input) {
213
+ if (!Array.isArray(input)) {
214
+ return [];
215
+ }
216
+ const gates = [];
217
+ for (const value of input) {
218
+ if (!CODEX_REVIEW_GATES.includes(value) || gates.includes(value)) {
219
+ continue;
220
+ }
221
+ gates.push(value);
222
+ }
223
+ return CODEX_REVIEW_GATES.filter((gate) => gates.includes(gate));
224
+ }
225
+ function normalizeSettingsFile(input) {
226
+ const settings = {
177
227
  version: 1,
178
228
  preferences: normalizePreferences(input.preferences),
179
229
  translation: normalizeTranslationConfig(input.translation),
180
230
  recentRepositoryPaths: normalizeRecentRepositoryPaths(input.recentRepositoryPaths)
181
231
  };
232
+ const codexReview = normalizeCodexReviewSettingsState(input.codexReview);
233
+ if (codexReview) {
234
+ settings.codexReview = codexReview;
235
+ }
236
+ return settings;
182
237
  }
183
238
  function normalizePreferences(input) {
184
239
  const candidate = isObject(input) ? input : {};
@@ -4,7 +4,6 @@ import { CODEX_REVIEW_GATES } from "../../shared/types/codex-review.js";
4
4
  import { VcmError } from "../errors.js";
5
5
  import { resolveRepoPath } from "../adapters/filesystem.js";
6
6
  import { submitTerminalInput } from "../runtime/terminal-submit.js";
7
- import { renderCodexConfigHarnessRules } from "../templates/harness/codex-review.js";
8
7
  import { getTaskRuntimeRepoRoot } from "./task-service.js";
9
8
  const CODEX_DIR = ".ai/codex";
10
9
  const CODEX_CONFIG_PATH = ".ai/codex/config.toml";
@@ -39,12 +38,13 @@ export function createCodexReviewService(deps) {
39
38
  const projectConfig = await deps.projectService.loadConfig(repoRoot);
40
39
  const task = await deps.taskService.loadTask(repoRoot, taskSlug);
41
40
  const taskRepoRoot = getTaskRuntimeRepoRoot(task);
41
+ const reviewSettings = await deps.appSettings.getCodexReviewSettings(repoRoot, taskSlug);
42
42
  return {
43
43
  repoRoot,
44
44
  taskSlug,
45
45
  taskRepoRoot,
46
46
  stateRoot: projectConfig.stateRoot,
47
- config: await loadRuntimeConfig(deps.fs, taskRepoRoot)
47
+ config: loadRuntimeConfig(reviewSettings)
48
48
  };
49
49
  }
50
50
  async function requestReviewGateInternal(repoRoot, taskSlug, gate, options = {}) {
@@ -148,6 +148,7 @@ export function createCodexReviewService(deps) {
148
148
  return;
149
149
  }
150
150
  activeRuns.add(runKey);
151
+ let codexTurnStarted = false;
151
152
  try {
152
153
  const timestamp = now();
153
154
  await updateGateRecord(context, gate, {
@@ -169,13 +170,24 @@ export function createCodexReviewService(deps) {
169
170
  });
170
171
  }
171
172
  const session = await ensureCodexReviewerSession(context);
172
- await deps.sessionService.markRoleActivityRunning(context.repoRoot, context.taskSlug, CODEX_REVIEWER_ROLE);
173
173
  await submitTerminalInput(deps.runtime, session.id, prompt);
174
+ await deps.sessionService.markRoleActivityRunning(context.repoRoot, context.taskSlug, CODEX_REVIEWER_ROLE);
175
+ await deps.roundService.recordRoleTurnEvent({
176
+ repoRoot: context.repoRoot,
177
+ stateRepoRoot: context.taskRepoRoot,
178
+ stateRoot: context.stateRoot,
179
+ taskSlug: context.taskSlug,
180
+ role: CODEX_REVIEWER_ROLE,
181
+ eventName: "UserPromptSubmit"
182
+ });
183
+ codexTurnStarted = true;
174
184
  const parsed = await waitForGateReport(deps.fs, context.taskRepoRoot, gate, requestId, now(), {
175
185
  intervalMs: reportPollIntervalMs,
176
186
  timeoutMs: reportTimeoutMs
177
187
  });
178
188
  const completedAt = now();
189
+ await recordCodexReviewerTurnStop(context, codexTurnStarted);
190
+ codexTurnStarted = false;
179
191
  await updateGateRecord(context, gate, {
180
192
  status: "completed",
181
193
  decision: parsed.decision,
@@ -197,6 +209,8 @@ export function createCodexReviewService(deps) {
197
209
  catch (error) {
198
210
  const timestamp = now();
199
211
  const message = errorMessage(error);
212
+ await recordCodexReviewerTurnStop(context, codexTurnStarted);
213
+ codexTurnStarted = false;
200
214
  await updateGateRecord(context, gate, {
201
215
  status: "failed",
202
216
  error: message,
@@ -215,6 +229,20 @@ export function createCodexReviewService(deps) {
215
229
  activeRuns.delete(runKey);
216
230
  }
217
231
  }
232
+ async function recordCodexReviewerTurnStop(context, shouldRecord) {
233
+ if (!shouldRecord) {
234
+ return;
235
+ }
236
+ await deps.sessionService.markRoleActivityIdle(context.repoRoot, context.taskSlug, CODEX_REVIEWER_ROLE);
237
+ await deps.roundService.recordRoleTurnEvent({
238
+ repoRoot: context.repoRoot,
239
+ stateRepoRoot: context.taskRepoRoot,
240
+ stateRoot: context.stateRoot,
241
+ taskSlug: context.taskSlug,
242
+ role: CODEX_REVIEWER_ROLE,
243
+ eventName: "Stop"
244
+ });
245
+ }
218
246
  async function ensureCodexReviewerSession(context) {
219
247
  const existing = await deps.sessionService.getRoleSession(context.repoRoot, context.taskSlug, CODEX_REVIEWER_ROLE);
220
248
  if (existing?.status === "running" && deps.runtime.getSession(existing.id)) {
@@ -265,6 +293,14 @@ export function createCodexReviewService(deps) {
265
293
  try {
266
294
  await submitTerminalInput(deps.runtime, session.id, prompt);
267
295
  await deps.sessionService.markRoleActivityRunning(context.repoRoot, context.taskSlug, "project-manager");
296
+ await deps.roundService.recordRoleTurnEvent({
297
+ repoRoot: context.repoRoot,
298
+ stateRepoRoot: context.taskRepoRoot,
299
+ stateRoot: context.stateRoot,
300
+ taskSlug: context.taskSlug,
301
+ role: "project-manager",
302
+ eventName: "UserPromptSubmit"
303
+ });
268
304
  await updateGateRecord(context, gate, {
269
305
  callbackStatus: "sent",
270
306
  callbackError: undefined,
@@ -285,8 +321,8 @@ export function createCodexReviewService(deps) {
285
321
  return loadIndex(deps.fs, context, now());
286
322
  },
287
323
  async updateSettings(repoRoot, taskSlug, input) {
288
- const context = await getContext(repoRoot, taskSlug);
289
- const requiredGates = new Set(context.config.enabled ? context.config.requiredGates : []);
324
+ const currentSettings = await deps.appSettings.getCodexReviewSettings(repoRoot, taskSlug);
325
+ const requiredGates = new Set(currentSettings.requiredGates);
290
326
  for (const [gate, enabled] of Object.entries(input?.gates ?? {})) {
291
327
  if (!isCodexReviewGate(gate)) {
292
328
  continue;
@@ -298,7 +334,7 @@ export function createCodexReviewService(deps) {
298
334
  requiredGates.delete(gate);
299
335
  }
300
336
  }
301
- await writeRuntimeConfigSettings(deps.fs, context.taskRepoRoot, [...requiredGates]);
337
+ await deps.appSettings.updateCodexReviewSettings(repoRoot, taskSlug, [...requiredGates]);
302
338
  const nextContext = await getContext(repoRoot, taskSlug);
303
339
  const index = await loadIndex(deps.fs, nextContext, now());
304
340
  await saveIndex(deps.fs, nextContext.taskRepoRoot, {
@@ -372,75 +408,12 @@ export function createCodexReviewService(deps) {
372
408
  export function isCodexReviewGate(value) {
373
409
  return CODEX_REVIEW_GATES.includes(value);
374
410
  }
375
- async function loadRuntimeConfig(fs, taskRepoRoot) {
376
- const configPath = resolveRepoPath(taskRepoRoot, CODEX_CONFIG_PATH);
377
- if (!(await fs.pathExists(configPath))) {
378
- return {
379
- enabled: false,
380
- requiredGates: [],
381
- command: "codex"
382
- };
383
- }
384
- const content = await fs.readText(configPath);
385
- const section = extractTomlSection(content, "vcm.codex_review");
386
- const enabled = parseTomlBoolean(section, "enabled") ?? false;
387
- const parsedGates = parseTomlStringArray(section, "required_gates").filter(isCodexReviewGate);
411
+ function loadRuntimeConfig(reviewSettings) {
388
412
  return {
389
- enabled,
390
- requiredGates: parsedGates.length > 0 ? parsedGates : enabled ? [...CODEX_REVIEW_GATES] : [],
391
- model: parseTomlString(content, "model"),
392
- modelReasoningEffort: parseTomlString(content, "model_reasoning_effort"),
393
- command: parseTomlString(section, "command") ?? "codex"
413
+ enabled: reviewSettings.enabled,
414
+ requiredGates: reviewSettings.requiredGates
394
415
  };
395
416
  }
396
- async function writeRuntimeConfigSettings(fs, taskRepoRoot, requiredGates) {
397
- const configPath = resolveRepoPath(taskRepoRoot, CODEX_CONFIG_PATH);
398
- const current = await fs.pathExists(configPath)
399
- ? await fs.readText(configPath)
400
- : renderCodexConfigHarnessRules();
401
- const section = renderCodexReviewSettingsSection(requiredGates);
402
- const next = replaceTomlSection(current, "vcm.codex_review", section);
403
- await fs.writeText(configPath, next);
404
- }
405
- function renderCodexReviewSettingsSection(requiredGates) {
406
- if (requiredGates.length === 0) {
407
- return [
408
- "[vcm.codex_review]",
409
- "enabled = false",
410
- "required_gates = []"
411
- ].join("\n");
412
- }
413
- const lines = [
414
- "[vcm.codex_review]",
415
- "enabled = true",
416
- "required_gates = [",
417
- ...CODEX_REVIEW_GATES
418
- .filter((gate) => requiredGates.includes(gate))
419
- .map((gate) => ` "${gate}",`),
420
- "]"
421
- ];
422
- return lines.join("\n");
423
- }
424
- function replaceTomlSection(content, sectionName, nextSection) {
425
- const lines = content.replace(/\s+$/g, "").split(/\r?\n/);
426
- const header = `[${sectionName}]`;
427
- const start = lines.findIndex((line) => line.trim() === header);
428
- if (start < 0) {
429
- return `${lines.join("\n").trimEnd()}\n\n${nextSection}\n`;
430
- }
431
- let end = lines.length;
432
- for (let index = start + 1; index < lines.length; index += 1) {
433
- if (/^\s*\[[^\]]+\]\s*$/.test(lines[index])) {
434
- end = index;
435
- break;
436
- }
437
- }
438
- return [
439
- ...lines.slice(0, start),
440
- nextSection,
441
- ...lines.slice(end)
442
- ].join("\n").trimEnd() + "\n";
443
- }
444
417
  async function loadIndex(fs, context, timestamp) {
445
418
  const indexPath = getIndexPath(context.taskRepoRoot);
446
419
  const raw = await readJsonOrNull(fs, indexPath);
@@ -684,41 +657,6 @@ async function readJsonOrNull(fs, targetPath) {
684
657
  return null;
685
658
  }
686
659
  }
687
- function extractTomlSection(content, sectionName) {
688
- const lines = content.split(/\r?\n/);
689
- const sectionHeader = `[${sectionName}]`;
690
- const collected = [];
691
- let inSection = false;
692
- for (const line of lines) {
693
- const trimmed = line.trim();
694
- if (/^\[[^\]]+\]$/.test(trimmed)) {
695
- if (inSection) {
696
- break;
697
- }
698
- inSection = trimmed === sectionHeader;
699
- continue;
700
- }
701
- if (inSection) {
702
- collected.push(line);
703
- }
704
- }
705
- return collected.join("\n");
706
- }
707
- function parseTomlBoolean(section, key) {
708
- const match = section.match(new RegExp(`^\\s*${escapeRegex(key)}\\s*=\\s*(true|false)\\s*(?:#.*)?$`, "mi"));
709
- return match ? match[1] === "true" : undefined;
710
- }
711
- function parseTomlString(content, key) {
712
- const match = content.match(new RegExp(`^\\s*${escapeRegex(key)}\\s*=\\s*"([^"]*)"\\s*(?:#.*)?$`, "mi"));
713
- return match?.[1];
714
- }
715
- function parseTomlStringArray(section, key) {
716
- const match = section.match(new RegExp(`${escapeRegex(key)}\\s*=\\s*\\[([\\s\\S]*?)\\]`, "m"));
717
- if (!match) {
718
- return [];
719
- }
720
- return [...match[1].matchAll(/"([^"]+)"/g)].map((candidate) => candidate[1]);
721
- }
722
660
  function matchField(content, field) {
723
661
  const match = content.match(new RegExp(`^\\s*${escapeRegex(field)}\\s*:\\s*(.+?)\\s*$`, "mi"));
724
662
  return match?.[1]?.trim();
@@ -39,6 +39,51 @@ export function createRoundService(deps) {
39
39
  status
40
40
  });
41
41
  }
42
+ async function findActiveRoleSession(input, timestamp) {
43
+ if (!input.repoRoot || !deps.sessionService) {
44
+ return undefined;
45
+ }
46
+ let sessions;
47
+ try {
48
+ sessions = await deps.sessionService.listRoleSessions(input.repoRoot, input.taskSlug);
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ const active = sessions
54
+ .filter((session) => session.status === "running" && session.activityStatus === "running")
55
+ .sort((left, right) => timestampMs(roleActivityTimestamp(right, timestamp)) - timestampMs(roleActivityTimestamp(left, timestamp)))[0];
56
+ if (!active) {
57
+ return undefined;
58
+ }
59
+ return {
60
+ role: active.role,
61
+ startedAt: roleActivityTimestamp(active, timestamp)
62
+ };
63
+ }
64
+ async function syncRoundFromActiveRoleSession(input, state, timestamp) {
65
+ const current = state.currentRound;
66
+ if (current?.status === "running" && current.activeTurnStartedAt) {
67
+ return undefined;
68
+ }
69
+ const active = await findActiveRoleSession(input, timestamp);
70
+ if (!active) {
71
+ return undefined;
72
+ }
73
+ const shouldStartNewRound = !current || current.status === "stopped";
74
+ const next = applyPromptSubmitted({
75
+ state,
76
+ taskSlug: input.taskSlug,
77
+ role: active.role,
78
+ timestamp: active.startedAt,
79
+ updatedAt: timestamp,
80
+ roundId: shouldStartNewRound ? id() : current.id
81
+ });
82
+ await save(input, next);
83
+ clearSettleTimer(input);
84
+ await updateSessionStatus(input, "running");
85
+ return next;
86
+ }
42
87
  async function settleIfNeeded(input, state, timestamp) {
43
88
  const current = state.currentRound;
44
89
  if (current?.status !== "running" ||
@@ -49,6 +94,10 @@ export function createRoundService(deps) {
49
94
  if (timestamp < current.settleDeadlineAt) {
50
95
  return state;
51
96
  }
97
+ const activeSessionState = await syncRoundFromActiveRoleSession(input, state, timestamp);
98
+ if (activeSessionState) {
99
+ return activeSessionState;
100
+ }
52
101
  const stopped = {
53
102
  ...current,
54
103
  status: "stopped",
@@ -76,6 +125,10 @@ export function createRoundService(deps) {
76
125
  return;
77
126
  }
78
127
  const timestamp = maxIsoTimestamp(now(), settleDeadlineAt);
128
+ const activeSessionState = await syncRoundFromActiveRoleSession(input, state, timestamp);
129
+ if (activeSessionState) {
130
+ return;
131
+ }
79
132
  if (settleGuard) {
80
133
  const decision = await runSettleGuard(settleGuard, {
81
134
  ...input,
@@ -178,7 +231,10 @@ export function createRoundService(deps) {
178
231
  async getSessionRoundState(input) {
179
232
  return withTaskLock(input, async () => {
180
233
  const timestamp = now();
181
- return toSessionRoundState(await load(input), timestamp);
234
+ const loaded = await load(input);
235
+ const synced = await syncRoundFromActiveRoleSession(input, loaded, timestamp);
236
+ const settled = synced ?? await settleIfNeeded(input, loaded, timestamp);
237
+ return toSessionRoundState(settled, timestamp);
182
238
  });
183
239
  },
184
240
  recordRoleTurnEvent,
@@ -198,7 +254,19 @@ export function applyRoundHookEvent(input) {
198
254
  return applyStop(input);
199
255
  }
200
256
  function applyPromptSubmitted(input) {
257
+ const updatedAt = input.updatedAt ?? input.timestamp;
201
258
  const current = input.state.currentRound;
259
+ if (current?.status === "running" && current.activeTurnStartedAt && current.activeRole === input.role) {
260
+ return {
261
+ ...input.state,
262
+ taskSlug: input.taskSlug,
263
+ currentRound: {
264
+ ...current,
265
+ roles: appendUniqueRole(current.roles, input.role)
266
+ },
267
+ updatedAt
268
+ };
269
+ }
202
270
  const shouldStartNewRound = !current || current.status === "stopped";
203
271
  const totalRoundCount = shouldStartNewRound
204
272
  ? input.state.totalRoundCount + 1
@@ -236,12 +304,12 @@ function applyPromptSubmitted(input) {
236
304
  totalTurnCount: input.state.totalTurnCount + 1,
237
305
  totalCompletedTurnCount: input.state.totalCompletedTurnCount,
238
306
  totalCcActiveMs: input.state.totalCcActiveMs,
239
- updatedAt: input.timestamp
307
+ updatedAt
240
308
  };
241
309
  }
242
310
  function applyStop(input) {
243
311
  const current = input.state.currentRound;
244
- if (!current || current.status === "stopped" || !current.activeTurnStartedAt) {
312
+ if (!current || current.status === "stopped" || !current.activeTurnStartedAt || current.activeRole !== input.role) {
245
313
  return {
246
314
  ...input.state,
247
315
  taskSlug: input.taskSlug,
@@ -389,6 +457,13 @@ function addMilliseconds(value, milliseconds) {
389
457
  function maxIsoTimestamp(left, right) {
390
458
  return Date.parse(left) >= Date.parse(right) ? left : right;
391
459
  }
460
+ function roleActivityTimestamp(session, fallback) {
461
+ return session.lastTurnStartedAt ?? session.lastHookEventAt ?? session.updatedAt ?? fallback;
462
+ }
463
+ function timestampMs(value) {
464
+ const parsed = Date.parse(value);
465
+ return Number.isFinite(parsed) ? parsed : 0;
466
+ }
392
467
  async function runSettleGuard(settleGuard, input) {
393
468
  try {
394
469
  return await settleGuard(input);