@schoolai/shipyard 3.2.0 → 3.2.1-rc.20260421.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,10 +4,11 @@ import {
4
4
  appendTrailerToMessage,
5
5
  emitCommitAttributed,
6
6
  emitPrAttributed,
7
+ emitPrMerged,
7
8
  emitTaskEnded,
8
9
  emitTaskStarted,
9
10
  hasShipyardTrailer
10
- } from "./chunk-FD2QK7IN.js";
11
+ } from "./chunk-DKMDBOFU.js";
11
12
  import {
12
13
  AuthGitHubCallbackRequestSchema,
13
14
  AuthGitHubCallbackResponseSchema,
@@ -46,7 +47,7 @@ import {
46
47
  VaultKeyPutRequestSchema,
47
48
  VaultKeyPutResponseSchema,
48
49
  classifyClaudeCodeCompatibility
49
- } from "./chunk-5SJBSLGT.js";
50
+ } from "./chunk-FMMRZTOF.js";
50
51
  import "./chunk-EHQITHQX.js";
51
52
  import {
52
53
  loadAuthToken
@@ -82,7 +83,7 @@ import {
82
83
  } from "./chunk-2H7UOFLK.js";
83
84
 
84
85
  // src/services/serve.ts
85
- import { mkdir as mkdir24 } from "fs/promises";
86
+ import { mkdir as mkdir24, realpath as realpath2 } from "fs/promises";
86
87
  import { join as join55 } from "path";
87
88
  import { query as query2 } from "@anthropic-ai/claude-agent-sdk";
88
89
 
@@ -27123,7 +27124,10 @@ var MessageSchema = external_exports.object({
27123
27124
  reasoningEffort: ReasoningEffortSchema.nullable().optional(),
27124
27125
  permissionMode: PermissionModeSchema2.nullable().optional(),
27125
27126
  isSynthetic: external_exports.boolean().optional(),
27126
- anchorToolUseId: external_exports.string().optional()
27127
+ anchorToolUseId: external_exports.string().optional(),
27128
+ correlationId: external_exports.string().optional(),
27129
+ batchCorrelationIds: external_exports.array(external_exports.string()).optional(),
27130
+ sdkUuid: external_exports.string().optional()
27127
27131
  });
27128
27132
  var ChannelSchema = external_exports.object({
27129
27133
  id: external_exports.string(),
@@ -27286,9 +27290,14 @@ var TaskRecordSchema = external_exports.object({
27286
27290
  abandonedAt: external_exports.number().nullable().optional(),
27287
27291
  lastPlanDetection: PlanDetectionSchema.optional(),
27288
27292
  lastActivityAt: external_exports.number().default(0),
27289
- appliedTemplateId: external_exports.string().optional()
27293
+ appliedTemplateId: external_exports.string().optional(),
27294
+ roiStartedEmitted: external_exports.boolean().default(false),
27295
+ totalTurnCount: external_exports.number().int().nonnegative().default(0),
27296
+ mergedAt: external_exports.number().int().positive().nullable().default(null),
27297
+ attributedCommitShas: external_exports.array(external_exports.string()).max(50).default([]),
27298
+ lastCommitScanSha: external_exports.string().nullable().default(null)
27290
27299
  });
27291
- var TASK_STORE_VERSION = 10;
27300
+ var TASK_STORE_VERSION = 11;
27292
27301
  var TaskStoreSchema = external_exports.object({
27293
27302
  schemaVersion: external_exports.number(),
27294
27303
  tasks: external_exports.record(external_exports.string(), TaskRecordSchema)
@@ -27305,6 +27314,9 @@ function migrateTaskStore(raw) {
27305
27314
  if (version < 10) {
27306
27315
  backfillLastActivityAt(prop(raw, "tasks"));
27307
27316
  }
27317
+ if (version < 11) {
27318
+ backfillRoiStartedEmitted(prop(raw, "tasks"));
27319
+ }
27308
27320
  return TaskStoreSchema.parse({ ...raw, schemaVersion: TASK_STORE_VERSION });
27309
27321
  }
27310
27322
  function backfillLastActivityAt(tasksRaw) {
@@ -27322,6 +27334,23 @@ function backfillLastActivityAt(tasksRaw) {
27322
27334
  Object.assign(record, { lastActivityAt: fallback });
27323
27335
  }
27324
27336
  }
27337
+ function backfillRoiStartedEmitted(tasksRaw) {
27338
+ if (typeof tasksRaw !== "object" || tasksRaw === null)
27339
+ return;
27340
+ const alreadyRanStatuses = /* @__PURE__ */ new Set(["in_progress", "input_required", "completed", "canceled"]);
27341
+ for (const record of Object.values(tasksRaw)) {
27342
+ if (typeof record !== "object" || record === null)
27343
+ continue;
27344
+ const existing = prop(record, "roiStartedEmitted");
27345
+ if (existing === true)
27346
+ continue;
27347
+ const taskStartedAt = prop(record, "taskStartedAt");
27348
+ const status = prop(record, "status");
27349
+ if (taskStartedAt != null || typeof status === "string" && alreadyRanStatuses.has(status)) {
27350
+ Object.assign(record, { roiStartedEmitted: true });
27351
+ }
27352
+ }
27353
+ }
27325
27354
  var TemplateItemSchema = external_exports.object({
27326
27355
  id: external_exports.string(),
27327
27356
  content: external_exports.string(),
@@ -27584,6 +27613,7 @@ var CheckpointFileEntrySchema = external_exports.object({
27584
27613
  var BrowserToDaemonMessageSchema = external_exports.discriminatedUnion("type", [
27585
27614
  external_exports.object({
27586
27615
  type: external_exports.literal("send_message"),
27616
+ correlationId: external_exports.string(),
27587
27617
  content: external_exports.array(ContentBlockSchema),
27588
27618
  model: external_exports.string().optional(),
27589
27619
  reasoningEffort: ReasoningEffortSchema.optional(),
@@ -27595,7 +27625,8 @@ var BrowserToDaemonMessageSchema = external_exports.discriminatedUnion("type", [
27595
27625
  external_exports.object({
27596
27626
  type: external_exports.literal("subscribe"),
27597
27627
  sinceSeqNo: external_exports.number(),
27598
- maxMessages: external_exports.number().int().positive().optional()
27628
+ maxMessages: external_exports.number().int().positive().optional(),
27629
+ inFlightCorrelationIds: external_exports.array(external_exports.string()).optional()
27599
27630
  }),
27600
27631
  external_exports.object({ type: external_exports.literal("cancel_queued") }),
27601
27632
  external_exports.object({
@@ -27662,6 +27693,19 @@ var DaemonToBrowserMessageSchema = external_exports.discriminatedUnion("type", [
27662
27693
  type: external_exports.literal("older_messages"),
27663
27694
  messages: external_exports.array(MessageSchema),
27664
27695
  hasMore: external_exports.boolean()
27696
+ }),
27697
+ external_exports.object({
27698
+ type: external_exports.literal("send_message_ack"),
27699
+ correlationId: external_exports.string(),
27700
+ stage: external_exports.enum(["accepted", "persisted", "forwarded", "confirmed", "rejected"]),
27701
+ error: external_exports.string().optional()
27702
+ }),
27703
+ external_exports.object({
27704
+ type: external_exports.literal("correlation_status_snapshot"),
27705
+ entries: external_exports.array(external_exports.object({
27706
+ correlationId: external_exports.string(),
27707
+ status: external_exports.enum(["unknown", "persisted", "forwarded", "confirmed", "rejected"])
27708
+ }))
27665
27709
  })
27666
27710
  ]);
27667
27711
  var PermissionRequestPayloadSchema = external_exports.object({
@@ -27757,7 +27801,8 @@ var BrowserToDaemonControlMessageSchema = external_exports.discriminatedUnion("t
27757
27801
  }),
27758
27802
  external_exports.object({
27759
27803
  type: external_exports.literal("update_settings"),
27760
- settings: DaemonSettingsSchema
27804
+ settings: DaemonSettingsSchema,
27805
+ correlationId: external_exports.string().optional()
27761
27806
  }),
27762
27807
  external_exports.object({
27763
27808
  type: external_exports.literal("update_task_settings"),
@@ -28067,7 +28112,8 @@ var DaemonToBrowserControlMessageSchema = external_exports.discriminatedUnion("t
28067
28112
  external_exports.object({
28068
28113
  type: external_exports.literal("settings_ack"),
28069
28114
  settings: DaemonSettingsSchema,
28070
- taskId: external_exports.string().optional()
28115
+ taskId: external_exports.string().optional(),
28116
+ correlationId: external_exports.string().optional()
28071
28117
  }),
28072
28118
  external_exports.object({
28073
28119
  type: external_exports.literal("enhance_prompt_chunk"),
@@ -28375,6 +28421,15 @@ var DaemonToBrowserControlMessageSchema = external_exports.discriminatedUnion("t
28375
28421
  external_exports.object({ type: external_exports.literal("template_list"), templates: external_exports.array(TaskTemplateRecordSchema) }),
28376
28422
  external_exports.object({ type: external_exports.literal("template_updated"), template: TaskTemplateRecordSchema }),
28377
28423
  external_exports.object({ type: external_exports.literal("template_deleted"), templateId: external_exports.string() }),
28424
+ external_exports.object({
28425
+ type: external_exports.literal("task_created_ack"),
28426
+ taskId: external_exports.string(),
28427
+ templateId: external_exports.string().nullable(),
28428
+ appliedTemplate: external_exports.object({
28429
+ templateId: external_exports.string(),
28430
+ todos: external_exports.array(external_exports.object({ id: external_exports.string(), content: external_exports.string(), deps: external_exports.array(external_exports.string()) }))
28431
+ }).nullable()
28432
+ }),
28378
28433
  external_exports.object({
28379
28434
  type: external_exports.literal("review_comment"),
28380
28435
  taskId: external_exports.string(),
@@ -28684,7 +28739,11 @@ var DaemonToFileIOMessageSchema = external_exports.discriminatedUnion("type", [
28684
28739
  }),
28685
28740
  external_exports.object({ type: external_exports.literal("git_stage_result"), requestId: external_exports.string() }),
28686
28741
  external_exports.object({ type: external_exports.literal("git_unstage_result"), requestId: external_exports.string() }),
28687
- external_exports.object({ type: external_exports.literal("set_cwd_ack"), requestId: external_exports.string() }),
28742
+ external_exports.object({
28743
+ type: external_exports.literal("set_cwd_ack"),
28744
+ requestId: external_exports.string(),
28745
+ canonical: external_exports.string().optional()
28746
+ }),
28688
28747
  external_exports.object({
28689
28748
  type: external_exports.literal("git_diff_turn_result"),
28690
28749
  requestId: external_exports.string(),
@@ -31391,7 +31450,7 @@ function nanoid(size2 = 21) {
31391
31450
  }
31392
31451
 
31393
31452
  // src/services/bootstrap/signaling.ts
31394
- var DAEMON_NPM_VERSION = true ? "3.2.0" : "unknown";
31453
+ var DAEMON_NPM_VERSION = true ? "3.2.1" : "unknown";
31395
31454
  function createDaemonSignaling(config2) {
31396
31455
  const agentId = config2.agentId ?? nanoid();
31397
31456
  function send(msg) {
@@ -35103,7 +35162,10 @@ async function rehydrateFromPersistence(persistence, taskManager, log, taskState
35103
35162
  initialTurnStats: record?.lastTurnStats,
35104
35163
  initialTokenCount: record?.lastTokenCount,
35105
35164
  initialPlanDetection: record?.lastPlanDetection,
35106
- mode: record?.mode
35165
+ mode: record?.mode,
35166
+ initialRoiStartedEmitted: record?.roiStartedEmitted ?? false,
35167
+ taskCreatedAt: record?.createdAt ?? Date.now(),
35168
+ initialTurnCount: record?.totalTurnCount ?? 0
35107
35169
  });
35108
35170
  log({ event: "task_restored", taskId: action.taskId, kind: "resumable" });
35109
35171
  break;
@@ -35120,6 +35182,50 @@ async function rehydrateFromPersistence(persistence, taskManager, log, taskState
35120
35182
  }
35121
35183
  }
35122
35184
  }
35185
+ async function applyMainQueueRehydrate(taskId, orchestrator, log) {
35186
+ let claimedByCollab = /* @__PURE__ */ new Set();
35187
+ try {
35188
+ claimedByCollab = await orchestrator.rehydrateCollabQueue();
35189
+ log({ event: "collab_queue_rehydrate_applied", taskId });
35190
+ } catch (err) {
35191
+ log({
35192
+ event: "collab_queue_rehydrate_failed",
35193
+ taskId,
35194
+ error: err instanceof Error ? err.message : String(err)
35195
+ });
35196
+ }
35197
+ try {
35198
+ const count = await orchestrator.rehydrateUnpushedMessages(claimedByCollab);
35199
+ if (count > 0) {
35200
+ log({ event: "unpushed_messages_rehydrate_applied", taskId, count });
35201
+ }
35202
+ } catch (err) {
35203
+ log({
35204
+ event: "unpushed_messages_rehydrate_failed",
35205
+ taskId,
35206
+ error: err instanceof Error ? err.message : String(err)
35207
+ });
35208
+ }
35209
+ }
35210
+ async function applyThreadQueueRehydrate(key, taskId, threadId, orchestrator, persistence, log) {
35211
+ try {
35212
+ const found2 = await orchestrator.rehydrateThreadQueue(threadId);
35213
+ if (found2) {
35214
+ log({ event: "collab_queue_thread_rehydrate_applied", taskId, threadId });
35215
+ } else {
35216
+ await persistence.clear(key).catch(() => {
35217
+ });
35218
+ log({ event: "collab_queue_thread_rehydrate_orphan_cleared", taskId, threadId });
35219
+ }
35220
+ } catch (err) {
35221
+ log({
35222
+ event: "collab_queue_thread_rehydrate_failed",
35223
+ taskId,
35224
+ threadId,
35225
+ error: err instanceof Error ? err.message : String(err)
35226
+ });
35227
+ }
35228
+ }
35123
35229
  async function rehydrateCollabQueues(persistence, taskManager, log) {
35124
35230
  await persistence.cleanupOrphanTmpFiles().catch(() => {
35125
35231
  });
@@ -35127,12 +35233,6 @@ async function rehydrateCollabQueues(persistence, taskManager, log) {
35127
35233
  if (keys3.length === 0) return;
35128
35234
  for (const key of keys3) {
35129
35235
  const parsed = parseQueueKey(key);
35130
- if (parsed.kind !== "main") {
35131
- await persistence.clear(key).catch(() => {
35132
- });
35133
- log({ event: "collab_queue_thread_rehydrate_skipped", key });
35134
- continue;
35135
- }
35136
35236
  const orchestrator = taskManager.getOrchestrator(parsed.taskId);
35137
35237
  if (!orchestrator) {
35138
35238
  await persistence.clear(key).catch(() => {
@@ -35140,15 +35240,17 @@ async function rehydrateCollabQueues(persistence, taskManager, log) {
35140
35240
  log({ event: "collab_queue_rehydrate_skipped", taskId: parsed.taskId, reason: "no_task" });
35141
35241
  continue;
35142
35242
  }
35143
- try {
35144
- await orchestrator.rehydrateCollabQueue();
35145
- log({ event: "collab_queue_rehydrate_applied", taskId: parsed.taskId });
35146
- } catch (err) {
35147
- log({
35148
- event: "collab_queue_rehydrate_failed",
35149
- taskId: parsed.taskId,
35150
- error: err instanceof Error ? err.message : String(err)
35151
- });
35243
+ if (parsed.kind === "main") {
35244
+ await applyMainQueueRehydrate(parsed.taskId, orchestrator, log);
35245
+ } else {
35246
+ await applyThreadQueueRehydrate(
35247
+ key,
35248
+ parsed.taskId,
35249
+ parsed.threadId,
35250
+ orchestrator,
35251
+ persistence,
35252
+ log
35253
+ );
35152
35254
  }
35153
35255
  }
35154
35256
  }
@@ -69677,6 +69779,7 @@ async function runAbandonedSweep(deps) {
69677
69779
  try {
69678
69780
  await deps.setAbandonedAt(taskId, now);
69679
69781
  markedAbandoned.push(taskId);
69782
+ deps.onTaskAbandoned(taskId, task, now);
69680
69783
  } catch (err) {
69681
69784
  deps.log?.({
69682
69785
  event: "abandoned_sweep_mark_failed",
@@ -69716,6 +69819,222 @@ function createAbandonedSweeper(deps, intervalMs = ABANDONED_SWEEP_INTERVAL_MS)
69716
69819
  };
69717
69820
  }
69718
69821
 
69822
+ // src/services/roi/inject-shipyard-trailer.ts
69823
+ function buildDefaultTrailerInjectDeps() {
69824
+ return {
69825
+ readCommitMessage: async (cwd) => runWithTimeout("git", ["log", "-1", "--pretty=%B"], cwd, 5e3),
69826
+ amendCommit: async (cwd, message) => {
69827
+ await runWithTimeout("git", ["commit", "--amend", "--no-edit", "-m", message], cwd, 15e3);
69828
+ },
69829
+ getHeadSha: async (cwd) => (await runWithTimeout("git", ["rev-parse", "HEAD"], cwd, 5e3)).trim(),
69830
+ getCurrentBranch: async (cwd) => (await runWithTimeout("git", ["rev-parse", "--abbrev-ref", "HEAD"], cwd, 5e3)).trim(),
69831
+ getRepoSlug: async (cwd) => {
69832
+ const url = (await runWithTimeout("git", ["config", "--get", "remote.origin.url"], cwd, 5e3).catch(
69833
+ () => ""
69834
+ )).trim();
69835
+ const match2 = url.match(/[:/]([^/:]+\/[^/:]+?)(\.git)?\s*$/);
69836
+ return match2?.[1] ?? "";
69837
+ }
69838
+ };
69839
+ }
69840
+ async function injectShipyardTrailer(ctx, deps) {
69841
+ const original = await deps.readCommitMessage(ctx.cwd);
69842
+ if (hasShipyardTrailer(original)) {
69843
+ return { injected: false, reason: "already_trailered" };
69844
+ }
69845
+ const newMessage = appendTrailerToMessage(original, ctx.trailer);
69846
+ await deps.amendCommit(ctx.cwd, newMessage);
69847
+ const [commitSha, branch, repo] = await Promise.all([
69848
+ deps.getHeadSha(ctx.cwd),
69849
+ deps.getCurrentBranch(ctx.cwd),
69850
+ deps.getRepoSlug(ctx.cwd)
69851
+ ]);
69852
+ return { injected: true, commitSha, branch, repo };
69853
+ }
69854
+
69855
+ // src/services/roi/commit-sweep.ts
69856
+ async function fetchCurrentHead(cwd) {
69857
+ return (await runWithTimeout("git", ["rev-parse", "HEAD"], cwd, 5e3)).trim();
69858
+ }
69859
+ async function fetchCommits(cwd, lastSeenSha) {
69860
+ let args;
69861
+ if (lastSeenSha) {
69862
+ const inHistory = await runWithTimeout("git", ["cat-file", "-t", lastSeenSha], cwd, 5e3).then(
69863
+ (t) => t.trim() === "commit",
69864
+ () => false
69865
+ );
69866
+ if (inHistory) {
69867
+ args = ["log", `${lastSeenSha}..HEAD`, "--pretty=%H%n%B%x00", "--reverse"];
69868
+ } else {
69869
+ args = ["log", "HEAD", "--since=5 minutes ago", "--pretty=%H%n%B%x00", "--reverse"];
69870
+ }
69871
+ } else {
69872
+ args = ["log", "HEAD", "--since=5 minutes ago", "--pretty=%H%n%B%x00", "--reverse"];
69873
+ }
69874
+ const raw = await runWithTimeout("git", args, cwd, 1e4);
69875
+ if (!raw) return [];
69876
+ return raw.split("\0").map((chunk) => chunk.trim()).filter(Boolean).map((chunk) => {
69877
+ const newlineIdx = chunk.indexOf("\n");
69878
+ if (newlineIdx === -1) return { sha: chunk.trim(), message: "" };
69879
+ const sha = chunk.slice(0, newlineIdx).trim();
69880
+ const message = chunk.slice(newlineIdx + 1).trim();
69881
+ return { sha, message };
69882
+ }).filter((c) => Boolean(c.sha));
69883
+ }
69884
+ async function handleHeadCommit(commit, input, deps) {
69885
+ const { cwd, taskId, userId } = input;
69886
+ const { sha } = commit;
69887
+ const payload = deps.getAttributionPayload(taskId);
69888
+ if (!payload) return;
69889
+ const trailer = {
69890
+ version: TRAILER_SCHEMA_VERSION,
69891
+ taskId,
69892
+ sessionId: payload.sessionId,
69893
+ model: payload.model,
69894
+ tokens: payload.tokens,
69895
+ costUsd: payload.costUsd,
69896
+ turnCount: payload.turnCount,
69897
+ attributionType: "originated",
69898
+ clientVersion: "shipyard-daemon"
69899
+ };
69900
+ try {
69901
+ const result = await deps.injectTrailer({ cwd, trailer }, buildDefaultTrailerInjectDeps());
69902
+ if (!result.injected) {
69903
+ if (result.reason !== "already_trailered") {
69904
+ deps.log({ event: "roi_trailer_not_injected", taskId, sha, reason: result.reason });
69905
+ }
69906
+ await deps.addAttributedCommitSha(taskId, sha);
69907
+ return;
69908
+ }
69909
+ const newSha = result.commitSha;
69910
+ if (newSha && result.repo && result.branch) {
69911
+ emitCommitAttributed(deps.metricsCollector, {
69912
+ taskId,
69913
+ userId,
69914
+ commitSha: newSha,
69915
+ repo: result.repo,
69916
+ branch: result.branch,
69917
+ model: trailer.model,
69918
+ tokens: trailer.tokens,
69919
+ costUsd: trailer.costUsd,
69920
+ turnCount: trailer.turnCount,
69921
+ attributionType: "originated"
69922
+ });
69923
+ await deps.addAttributedCommitSha(taskId, newSha);
69924
+ }
69925
+ } catch (err) {
69926
+ deps.log({
69927
+ event: "roi_trailer_injection_failed",
69928
+ taskId,
69929
+ sha,
69930
+ error: err instanceof Error ? err.message : String(err)
69931
+ });
69932
+ }
69933
+ }
69934
+ async function handleNonHeadCommit(commit, input, deps) {
69935
+ const { cwd, taskId, userId } = input;
69936
+ const { sha } = commit;
69937
+ const payload = deps.getAttributionPayload(taskId);
69938
+ if (!payload) {
69939
+ deps.log({ event: "roi_scan_skip_no_payload", taskId, sha });
69940
+ return;
69941
+ }
69942
+ const injectDeps = buildDefaultTrailerInjectDeps();
69943
+ try {
69944
+ const [branch, repo] = await Promise.all([
69945
+ injectDeps.getCurrentBranch(cwd),
69946
+ injectDeps.getRepoSlug(cwd)
69947
+ ]);
69948
+ emitCommitAttributed(deps.metricsCollector, {
69949
+ taskId,
69950
+ userId,
69951
+ commitSha: sha,
69952
+ repo,
69953
+ branch,
69954
+ model: payload.model,
69955
+ tokens: payload.tokens,
69956
+ costUsd: payload.costUsd,
69957
+ turnCount: payload.turnCount,
69958
+ attributionType: "extended"
69959
+ });
69960
+ await deps.addAttributedCommitSha(taskId, sha);
69961
+ } catch (err) {
69962
+ deps.log({
69963
+ event: "roi_extended_attribution_failed",
69964
+ taskId,
69965
+ sha,
69966
+ error: err instanceof Error ? err.message : String(err)
69967
+ });
69968
+ }
69969
+ }
69970
+ async function scanAndAttributeCommits(input, deps) {
69971
+ const { cwd, taskId, lastSeenSha, attributedCommitShas } = input;
69972
+ const [commits, headSha] = await Promise.all([
69973
+ fetchCommits(cwd, lastSeenSha),
69974
+ fetchCurrentHead(cwd)
69975
+ ]);
69976
+ if (commits.length === 0) {
69977
+ if (headSha !== lastSeenSha) {
69978
+ await deps.setLastCommitScanSha(taskId, headSha);
69979
+ }
69980
+ return;
69981
+ }
69982
+ for (const commit of commits) {
69983
+ const { sha, message } = commit;
69984
+ if (hasShipyardTrailer(message)) continue;
69985
+ if (attributedCommitShas.includes(sha)) continue;
69986
+ if (sha === headSha && deps.amendHead) {
69987
+ await handleHeadCommit(commit, input, deps);
69988
+ } else {
69989
+ await handleNonHeadCommit(commit, input, deps);
69990
+ }
69991
+ }
69992
+ await deps.setLastCommitScanSha(taskId, headSha);
69993
+ }
69994
+ var CommitSweepService = class {
69995
+ #deps;
69996
+ #intervalMs;
69997
+ #timer = null;
69998
+ constructor(deps, intervalMs = 6e4) {
69999
+ this.#deps = deps;
70000
+ this.#intervalMs = intervalMs;
70001
+ }
70002
+ start() {
70003
+ if (this.#timer) return;
70004
+ this.#timer = setInterval(() => {
70005
+ this.#runSweep().catch((err) => {
70006
+ this.#deps.log({
70007
+ event: "commit_sweep_failed",
70008
+ error: err instanceof Error ? err.message : String(err)
70009
+ });
70010
+ });
70011
+ }, this.#intervalMs);
70012
+ this.#timer.unref?.();
70013
+ }
70014
+ stop() {
70015
+ if (this.#timer) {
70016
+ clearInterval(this.#timer);
70017
+ this.#timer = null;
70018
+ }
70019
+ }
70020
+ async #runSweep() {
70021
+ const tasks = this.#deps.getActiveTasks();
70022
+ for (const { taskId, cwd, userId } of tasks) {
70023
+ const attribution = await this.#deps.getTaskAttribution(taskId);
70024
+ await scanAndAttributeCommits(
70025
+ {
70026
+ cwd,
70027
+ taskId,
70028
+ userId: attribution.userId || userId,
70029
+ lastSeenSha: attribution.lastSeenSha,
70030
+ attributedCommitShas: attribution.attributedCommitShas
70031
+ },
70032
+ this.#deps
70033
+ );
70034
+ }
70035
+ }
70036
+ };
70037
+
69719
70038
  // ../../node_modules/.pnpm/croner@10.0.1/node_modules/croner/dist/croner.js
69720
70039
  function T(s2) {
69721
70040
  return Date.UTC(s2.y, s2.m - 1, s2.d, s2.h, s2.i, s2.s);
@@ -81033,6 +81352,50 @@ function buildJsonlConversationStore(dataDir) {
81033
81352
  }
81034
81353
  return messages;
81035
81354
  }
81355
+ function contentFingerprint(message) {
81356
+ const texts = message.content.filter((b2) => b2.type === "text").map((b2) => b2.type === "text" ? b2.text : "").join("\0");
81357
+ return `${message.participantId}${texts}`;
81358
+ }
81359
+ function lookupByCorrelationId(existing, correlationId) {
81360
+ return existing.find((m2) => m2.senderKind === "human" && m2.correlationId === correlationId);
81361
+ }
81362
+ function lookupBySdkUuid(existing, sdkUuid) {
81363
+ return existing.find((m2) => m2.senderKind === "human" && m2.sdkUuid === sdkUuid);
81364
+ }
81365
+ function lookupByFingerprint(existing, fingerprint, windowStart) {
81366
+ return existing.find(
81367
+ (m2) => m2.senderKind === "human" && !m2.correlationId && !m2.sdkUuid && m2.timestamp >= windowStart && contentFingerprint(m2) === fingerprint
81368
+ );
81369
+ }
81370
+ function decideDedupByCorrelation(existing, message) {
81371
+ if (!message.correlationId) return null;
81372
+ const found2 = lookupByCorrelationId(existing, message.correlationId);
81373
+ if (!found2) return null;
81374
+ return { kind: "duplicate", seqNo: found2.seqNo, dedupKey: `corr:${message.correlationId}` };
81375
+ }
81376
+ function decideDedupBySdkUuid(existing, message) {
81377
+ if (!message.sdkUuid) return null;
81378
+ const found2 = lookupBySdkUuid(existing, message.sdkUuid);
81379
+ if (!found2) return null;
81380
+ return { kind: "duplicate", seqNo: found2.seqNo, dedupKey: `sdk:${message.sdkUuid}` };
81381
+ }
81382
+ function decideDedupByFingerprint(existing, message, dedupWindowMs, now) {
81383
+ if (message.correlationId || message.sdkUuid) return null;
81384
+ const fingerprint = contentFingerprint(message);
81385
+ const found2 = lookupByFingerprint(existing, fingerprint, now - dedupWindowMs);
81386
+ if (!found2) return null;
81387
+ return { kind: "duplicate", seqNo: found2.seqNo, dedupKey: `fp:${fingerprint}` };
81388
+ }
81389
+ function classifyDedupResult(existing, message, currentSeq, dedupWindowMs, now) {
81390
+ const byCorr = decideDedupByCorrelation(existing, message);
81391
+ if (byCorr) return byCorr;
81392
+ const bySdk = decideDedupBySdkUuid(existing, message);
81393
+ if (bySdk) return bySdk;
81394
+ const byFp = decideDedupByFingerprint(existing, message, dedupWindowMs, now);
81395
+ if (byFp) return byFp;
81396
+ const seqNo = currentSeq !== void 0 ? currentSeq + 1 : existing.length;
81397
+ return { kind: "append", seqNo };
81398
+ }
81036
81399
  return {
81037
81400
  async appendMessage(message) {
81038
81401
  let resolveSeqNo;
@@ -81064,6 +81427,88 @@ function buildJsonlConversationStore(dataDir) {
81064
81427
  channelQueues.set(message.channelId, next);
81065
81428
  return seqNoPromise;
81066
81429
  },
81430
+ async appendMessageDeduped(message, opts = {}) {
81431
+ const DEDUP_WINDOW_MS = opts.dedupWindowMs ?? 6e4;
81432
+ let resolveResult;
81433
+ let rejectResult;
81434
+ const resultPromise = new Promise((resolve4, reject) => {
81435
+ resolveResult = resolve4;
81436
+ rejectResult = reject;
81437
+ });
81438
+ const prev = channelQueues.get(message.channelId) ?? Promise.resolve();
81439
+ const next = prev.then(async () => {
81440
+ await ensureDir();
81441
+ const existing = await readLines(message.channelId);
81442
+ const currentSeq = seqCounters.get(message.channelId);
81443
+ const decision = classifyDedupResult(
81444
+ existing,
81445
+ message,
81446
+ currentSeq,
81447
+ DEDUP_WINDOW_MS,
81448
+ Date.now()
81449
+ );
81450
+ if (decision.kind === "duplicate") {
81451
+ resolveResult({
81452
+ seqNo: decision.seqNo,
81453
+ isDuplicate: true,
81454
+ dedupKey: decision.dedupKey
81455
+ });
81456
+ return;
81457
+ }
81458
+ seqCounters.set(message.channelId, decision.seqNo);
81459
+ const { channelId: _channelId, ...rest } = message;
81460
+ const line = JSON.stringify(rest);
81461
+ await appendFile2(channelPath(message.channelId), `${line}
81462
+ `, "utf-8");
81463
+ resolveResult({ seqNo: decision.seqNo, isDuplicate: false, dedupKey: null });
81464
+ }).catch((err) => {
81465
+ rejectResult(err);
81466
+ });
81467
+ channelQueues.set(message.channelId, next);
81468
+ return resultPromise;
81469
+ },
81470
+ async stampSdkUuid(channelId, primaryCorrelationId, sdkUuid, batchCorrelationIds) {
81471
+ let resolveVoid;
81472
+ let rejectVoid;
81473
+ const voidPromise = new Promise((resolve4, reject) => {
81474
+ resolveVoid = resolve4;
81475
+ rejectVoid = reject;
81476
+ });
81477
+ const prev = channelQueues.get(channelId) ?? Promise.resolve();
81478
+ const next = prev.then(async () => {
81479
+ const messages = await readLines(channelId);
81480
+ const target = messages.find(
81481
+ (m2) => m2.senderKind === "human" && m2.correlationId === primaryCorrelationId && !m2.sdkUuid
81482
+ );
81483
+ if (!target) {
81484
+ resolveVoid();
81485
+ return;
81486
+ }
81487
+ const secondaryIds = batchCorrelationIds && batchCorrelationIds.length > 0 ? batchCorrelationIds : void 0;
81488
+ const lines = messages.map((m2) => {
81489
+ const { seqNo: _seqNo, channelId: _channelId, ...rest } = m2;
81490
+ if (m2.seqNo === target.seqNo) {
81491
+ return JSON.stringify({
81492
+ ...rest,
81493
+ sdkUuid,
81494
+ ...secondaryIds ? { batchCorrelationIds: secondaryIds } : {}
81495
+ });
81496
+ }
81497
+ return JSON.stringify(rest);
81498
+ });
81499
+ const content = lines.length > 0 ? `${lines.join("\n")}
81500
+ ` : "";
81501
+ const filePath = channelPath(channelId);
81502
+ const tmpPath = `${filePath}.tmp`;
81503
+ await writeFile16(tmpPath, content, "utf-8");
81504
+ await rename10(tmpPath, filePath);
81505
+ resolveVoid();
81506
+ }).catch((err) => {
81507
+ rejectVoid(err);
81508
+ });
81509
+ channelQueues.set(channelId, next);
81510
+ return voidPromise;
81511
+ },
81067
81512
  async getMessages(channelId) {
81068
81513
  return readLines(channelId);
81069
81514
  },
@@ -81158,6 +81603,17 @@ function buildObservableConversationStore(inner) {
81158
81603
  notify(message.channelId, fullMessage);
81159
81604
  return seqNo;
81160
81605
  },
81606
+ async appendMessageDeduped(message, opts) {
81607
+ const result = await inner.appendMessageDeduped(message, opts);
81608
+ if (!result.isDuplicate) {
81609
+ const fullMessage = { ...message, seqNo: result.seqNo };
81610
+ notify(message.channelId, fullMessage);
81611
+ }
81612
+ return result;
81613
+ },
81614
+ stampSdkUuid(channelId, correlationId, sdkUuid, batchCorrelationIds) {
81615
+ return inner.stampSdkUuid(channelId, correlationId, sdkUuid, batchCorrelationIds);
81616
+ },
81161
81617
  getMessages(channelId) {
81162
81618
  return inner.getMessages(channelId);
81163
81619
  },
@@ -81350,6 +81806,7 @@ function buildJsonDocumentStore(opts) {
81350
81806
  if (!existing) return;
81351
81807
  const parsed = recordSchema.parse(existing);
81352
81808
  const updated = fn(parsed);
81809
+ if (updated === parsed) return;
81353
81810
  store.records[id] = updated;
81354
81811
  await atomicWrite3(store);
81355
81812
  notify({ kind: "set", id, data: updated });
@@ -81612,7 +82069,7 @@ var EVENT_BATCHING = {
81612
82069
  /** Resources: sliding window for file/plan/git/task pushes. */
81613
82070
  resources: { idleMs: 1e4, maxWindowMs: 6e4 },
81614
82071
  /** Messages: sliding window for collab message micro-batching. */
81615
- messages: { idleMs: 5e3, maxWindowMs: 3e4 }
82072
+ messages: { idleMs: 250, maxWindowMs: 3e4 }
81616
82073
  };
81617
82074
  var MAX_COLLAB_BATCH_SIZE = 4;
81618
82075
  var SlidingWindowTimer = class {
@@ -81702,18 +82159,58 @@ function formatCollabBatch(batch) {
81702
82159
  for (const block2 of nonTextBlocks) result.push(block2);
81703
82160
  return result;
81704
82161
  }
82162
+ var IDLE_STATES = /* @__PURE__ */ new Set(["warm_idle", "resumable_idle", "cold_idle"]);
81705
82163
  var CollabMessageQueue = class {
81706
82164
  #deps;
81707
82165
  #slots = /* @__PURE__ */ new Map();
81708
82166
  #maxBatchSize;
81709
82167
  #disposed = false;
82168
+ #unsubscribeThreadState = null;
81710
82169
  constructor(deps) {
81711
82170
  this.#deps = deps;
81712
82171
  this.#maxBatchSize = deps.maxBatchSize ?? MAX_COLLAB_BATCH_SIZE;
82172
+ if (deps.subscribeToThreadState) {
82173
+ this.#unsubscribeThreadState = deps.subscribeToThreadState((newState) => {
82174
+ if (IDLE_STATES.has(newState)) {
82175
+ this.onTurnComplete();
82176
+ }
82177
+ });
82178
+ }
82179
+ }
82180
+ /**
82181
+ * Wire Thread state-change notifications into this queue after construction.
82182
+ *
82183
+ * Use this when the Thread isn't available yet at queue-construction time
82184
+ * (e.g., the main collab queue, which is created before `#mainThread`).
82185
+ * Must be called at most once — subsequent calls are no-ops.
82186
+ *
82187
+ * On Thread transition to any idle state (`warm_idle`, `resumable_idle`,
82188
+ * `cold_idle`), releases all slots blocked in `waiting_for_turn`.
82189
+ */
82190
+ wireThreadStateSubscription(subscribe2) {
82191
+ if (this.#unsubscribeThreadState) return;
82192
+ this.#unsubscribeThreadState = subscribe2((newState) => {
82193
+ if (IDLE_STATES.has(newState)) {
82194
+ this.onTurnComplete();
82195
+ }
82196
+ });
81713
82197
  }
81714
82198
  enqueue(entry) {
81715
82199
  this.#enqueueInternal(entry, { persist: true });
81716
82200
  }
82201
+ #transitionSlot(key, slot, to, trigger) {
82202
+ const from2 = slot.state;
82203
+ if (from2 === to) return;
82204
+ slot.state = to;
82205
+ this.#deps.log({
82206
+ event: "collab_queue_slot_transition",
82207
+ participantId: key,
82208
+ queueKey: this.#deps.queueKey ?? null,
82209
+ from: from2,
82210
+ to,
82211
+ trigger
82212
+ });
82213
+ }
81717
82214
  #enqueueInternal(entry, opts) {
81718
82215
  if (this.#disposed) return;
81719
82216
  const participants = opts.participantsOverride ?? this.#deps.getCollabParticipants();
@@ -81733,7 +82230,7 @@ var CollabMessageQueue = class {
81733
82230
  });
81734
82231
  return;
81735
82232
  }
81736
- slot.state = "accumulating";
82233
+ this.#transitionSlot(key, slot, "accumulating", "enqueue");
81737
82234
  if (slot.pending.length >= this.#maxBatchSize) {
81738
82235
  this.#flushSlot(key);
81739
82236
  return;
@@ -81744,15 +82241,23 @@ var CollabMessageQueue = class {
81744
82241
  * Signal that the agent turn finished. Every participant slot that
81745
82242
  * was blocked in waiting_for_turn is released. Slots with pending
81746
82243
  * messages transition back to accumulating with a fresh idle window.
82244
+ *
82245
+ * Defers internally via queueMicrotask (Invariant #11) so callers can
82246
+ * invoke this synchronously from library callbacks without risking re-entry.
81747
82247
  */
81748
82248
  onTurnComplete() {
82249
+ queueMicrotask(() => {
82250
+ this.#doTurnComplete();
82251
+ });
82252
+ }
82253
+ #doTurnComplete() {
81749
82254
  for (const [key, slot] of this.#slots) {
81750
82255
  if (slot.state !== "waiting_for_turn") continue;
81751
82256
  if (slot.pending.length > 0) {
81752
- slot.state = "accumulating";
82257
+ this.#transitionSlot(key, slot, "accumulating", "turn_complete_with_pending");
81753
82258
  slot.timer.schedule();
81754
82259
  } else {
81755
- slot.state = "idle";
82260
+ this.#transitionSlot(key, slot, "idle", "turn_complete_drained");
81756
82261
  this.#slots.delete(key);
81757
82262
  }
81758
82263
  }
@@ -81764,7 +82269,7 @@ var CollabMessageQueue = class {
81764
82269
  slot.pending.length = 0;
81765
82270
  slot.timer.cancel();
81766
82271
  slot.timer.reset();
81767
- slot.state = "idle";
82272
+ this.#transitionSlot(key, slot, "idle", "cancel_queued");
81768
82273
  this.#clearPersistedFor(key);
81769
82274
  }
81770
82275
  this.#slots.clear();
@@ -81775,11 +82280,13 @@ var CollabMessageQueue = class {
81775
82280
  slot.pending.length = 0;
81776
82281
  slot.timer.dispose();
81777
82282
  this.#clearPersistedFor(key);
81778
- slot.state = "idle";
82283
+ this.#transitionSlot(key, slot, "idle", "reset");
81779
82284
  }
81780
82285
  this.#slots.clear();
81781
82286
  }
81782
82287
  dispose() {
82288
+ this.#unsubscribeThreadState?.();
82289
+ this.#unsubscribeThreadState = null;
81783
82290
  this.#flushAllImmediate();
81784
82291
  for (const slot of this.#slots.values()) {
81785
82292
  slot.timer.dispose();
@@ -81861,17 +82368,17 @@ var CollabMessageQueue = class {
81861
82368
  slot.timer.cancel();
81862
82369
  slot.timer.reset();
81863
82370
  if (slot.pending.length === 0) {
81864
- slot.state = "idle";
82371
+ this.#transitionSlot(key, slot, "idle", "flush_empty");
81865
82372
  return;
81866
82373
  }
81867
82374
  const batch = slot.pending.splice(0);
81868
82375
  const content = formatCollabBatch(batch);
81869
- slot.state = "waiting_for_turn";
82376
+ this.#transitionSlot(key, slot, "waiting_for_turn", "flush");
81870
82377
  try {
81871
82378
  this.#deps.forwardBatch(content, batch);
81872
82379
  } catch (err) {
81873
82380
  slot.pending.unshift(...batch);
81874
- slot.state = "accumulating";
82381
+ this.#transitionSlot(key, slot, "accumulating", "forward_failed");
81875
82382
  slot.timer.schedule();
81876
82383
  this.#deps.log({
81877
82384
  event: "collab_queue_forward_failed",
@@ -81881,6 +82388,12 @@ var CollabMessageQueue = class {
81881
82388
  return;
81882
82389
  }
81883
82390
  this.#clearPersistedFor(key);
82391
+ if (this.#deps.onBatchForwarded) {
82392
+ const batchCorrelationIds = batch.map((e) => e.correlationId).filter((id) => id !== void 0);
82393
+ if (batchCorrelationIds.length > 0) {
82394
+ this.#deps.onBatchForwarded(batchCorrelationIds);
82395
+ }
82396
+ }
81884
82397
  this.#deps.log({
81885
82398
  event: "collab_queue_flushed",
81886
82399
  participantId: key,
@@ -81901,6 +82414,12 @@ var CollabMessageQueue = class {
81901
82414
  const content = formatCollabBatch(batch);
81902
82415
  this.#deps.forwardBatch(content, batch);
81903
82416
  this.#clearPersistedFor(key);
82417
+ if (this.#deps.onBatchForwarded) {
82418
+ const batchCorrelationIds = batch.map((e) => e.correlationId).filter((id) => id !== void 0);
82419
+ if (batchCorrelationIds.length > 0) {
82420
+ this.#deps.onBatchForwarded(batchCorrelationIds);
82421
+ }
82422
+ }
81904
82423
  this.#deps.log({
81905
82424
  event: "collab_queue_flushed_immediate",
81906
82425
  participantId: key,
@@ -82634,6 +83153,7 @@ function handleSpawning(snapshot, event) {
82634
83153
  b2.log("spawning", "cold_idle", event.type);
82635
83154
  b2.taskStatus("input_required");
82636
83155
  b2.emitError(event.error, "spawn_failed");
83156
+ b2.effects.push({ type: "re_queue_initial", content: event.lastInitialContent ?? [] });
82637
83157
  return { state: "cold_idle", sessionId: null, rewindAtMessageId: null, effects: b2.effects };
82638
83158
  }
82639
83159
  if (event.type === "sdk_error" || event.type === "subprocess_died") {
@@ -82729,14 +83249,19 @@ function exitStoppingAlive(snapshot, trigger) {
82729
83249
  const { sessionId, rewindAtMessageId } = snapshot;
82730
83250
  const b2 = createEffectBuilder();
82731
83251
  b2.effects.push({ type: "clear_pending_inputs" });
82732
- b2.effects.push({ type: "clear_queue" });
83252
+ b2.effects.push({ type: "clear_unmarked_queue" });
82733
83253
  b2.log("stopping", "warm_idle", trigger);
82734
83254
  b2.taskStatus("input_required");
83255
+ b2.effects.push({ type: "push_message" });
82735
83256
  return { state: "warm_idle", sessionId, rewindAtMessageId, effects: b2.effects };
82736
83257
  }
82737
83258
  function handleStopping(snapshot, event) {
82738
83259
  const { sessionId, rewindAtMessageId } = snapshot;
82739
83260
  const b2 = createEffectBuilder();
83261
+ if (event.type === "user_message") {
83262
+ b2.effects.push({ type: "mark_queue_preserve" });
83263
+ return { state: "stopping", sessionId, rewindAtMessageId, effects: b2.effects };
83264
+ }
82740
83265
  if (event.type === "turn_complete") {
82741
83266
  return exitStoppingAlive(snapshot, event.type);
82742
83267
  }
@@ -82746,14 +83271,14 @@ function handleStopping(snapshot, event) {
82746
83271
  if (event.type === "close_acknowledged") {
82747
83272
  b2.log("stopping", "resumable_idle", event.type);
82748
83273
  b2.effects.push({ type: "clear_pending_inputs" });
82749
- b2.effects.push({ type: "clear_queue" });
83274
+ b2.effects.push({ type: "clear_unmarked_queue" });
82750
83275
  b2.taskStatus("input_required");
82751
83276
  return { state: "resumable_idle", sessionId, rewindAtMessageId, effects: b2.effects };
82752
83277
  }
82753
83278
  if (event.type === "subprocess_died" || event.type === "sdk_error") {
82754
83279
  b2.log("stopping", "resumable_idle", event.type);
82755
83280
  b2.effects.push({ type: "clear_pending_inputs" });
82756
- b2.effects.push({ type: "clear_queue" });
83281
+ b2.effects.push({ type: "clear_unmarked_queue" });
82757
83282
  b2.taskStatus("input_required");
82758
83283
  return { state: "resumable_idle", sessionId, rewindAtMessageId, effects: b2.effects };
82759
83284
  }
@@ -82761,7 +83286,7 @@ function handleStopping(snapshot, event) {
82761
83286
  b2.log("stopping", "resumable_idle", event.type);
82762
83287
  b2.effects.push({ type: "force_kill" });
82763
83288
  b2.effects.push({ type: "clear_pending_inputs" });
82764
- b2.effects.push({ type: "clear_queue" });
83289
+ b2.effects.push({ type: "clear_unmarked_queue" });
82765
83290
  b2.taskStatus("input_required");
82766
83291
  return { state: "resumable_idle", sessionId, rewindAtMessageId, effects: b2.effects };
82767
83292
  }
@@ -82823,6 +83348,12 @@ var AgentSessionManager = class {
82823
83348
  }
82824
83349
  sendMessage(content) {
82825
83350
  this.#queue.push({ content });
83351
+ this.#config.log({
83352
+ event: "session_send_message",
83353
+ taskId: this.#config.taskId,
83354
+ queueDepth: this.#queue.length,
83355
+ eventType: "user_message"
83356
+ });
82826
83357
  this.#dispatch({ type: "user_message" });
82827
83358
  }
82828
83359
  notifyInitReceived(sessionId) {
@@ -82852,8 +83383,8 @@ var AgentSessionManager = class {
82852
83383
  notifyCloseAcknowledged() {
82853
83384
  this.#dispatch({ type: "close_acknowledged" });
82854
83385
  }
82855
- notifySpawnFailed(error2) {
82856
- this.#dispatch({ type: "spawn_failed", error: error2 });
83386
+ notifySpawnFailed(error2, lastInitialContent) {
83387
+ this.#dispatch({ type: "spawn_failed", error: error2, lastInitialContent });
82857
83388
  }
82858
83389
  addPendingInput(toolUseId, request) {
82859
83390
  this.#pendingInputs.set(toolUseId, request);
@@ -82899,10 +83430,14 @@ var AgentSessionManager = class {
82899
83430
  }
82900
83431
  }
82901
83432
  #processEvent(event) {
83433
+ const prevState = this.#state;
82902
83434
  const result = transition(this.#snapshot(), event);
82903
83435
  this.#state = result.state;
82904
83436
  this.#sessionId = result.sessionId;
82905
83437
  this.#rewindAtMessageId = result.rewindAtMessageId;
83438
+ if (this.#state !== prevState) {
83439
+ this.#config.onSessionStateChange(this.#state, prevState);
83440
+ }
82906
83441
  this.#applyEffects(result.effects);
82907
83442
  this.#manageTimers();
82908
83443
  }
@@ -82914,17 +83449,9 @@ var AgentSessionManager = class {
82914
83449
  this.#config.onSpawn(effect.reason, initial?.content ?? []);
82915
83450
  break;
82916
83451
  }
82917
- case "push_message": {
82918
- const messages = [];
82919
- while (this.#queue.length > 0) {
82920
- const msg = this.#queue.shift();
82921
- if (msg) messages.push(msg.content);
82922
- }
82923
- if (messages.length > 0) {
82924
- this.#config.onFlushQueue(messages);
82925
- }
83452
+ case "push_message":
83453
+ this.#flushQueue();
82926
83454
  break;
82927
- }
82928
83455
  case "interrupt":
82929
83456
  this.#config.onInterrupt();
82930
83457
  break;
@@ -82955,6 +83482,17 @@ var AgentSessionManager = class {
82955
83482
  case "clear_queue":
82956
83483
  this.#queue = [];
82957
83484
  break;
83485
+ case "mark_queue_preserve":
83486
+ this.#markLastPreserved();
83487
+ break;
83488
+ case "clear_unmarked_queue":
83489
+ this.#queue = this.#queue.filter((q) => q.preserved);
83490
+ break;
83491
+ case "re_queue_initial":
83492
+ if (effect.content.length > 0) {
83493
+ this.#queue.unshift({ content: effect.content, preserved: true });
83494
+ }
83495
+ break;
82958
83496
  default: {
82959
83497
  const _exhaustive = effect;
82960
83498
  throw new Error(`Unhandled effect: ${JSON.stringify(_exhaustive)}`);
@@ -82962,6 +83500,22 @@ var AgentSessionManager = class {
82962
83500
  }
82963
83501
  }
82964
83502
  }
83503
+ #flushQueue() {
83504
+ const messages = [];
83505
+ while (this.#queue.length > 0) {
83506
+ const msg = this.#queue.shift();
83507
+ if (msg) messages.push(msg.content);
83508
+ }
83509
+ if (messages.length > 0) {
83510
+ this.#config.onFlushQueue(messages);
83511
+ }
83512
+ }
83513
+ #markLastPreserved() {
83514
+ if (this.#queue.length > 0) {
83515
+ const last = this.#queue[this.#queue.length - 1];
83516
+ if (last) last.preserved = true;
83517
+ }
83518
+ }
82965
83519
  #manageTimers() {
82966
83520
  if (this.#state === "warm_idle") {
82967
83521
  this.#clearStopTimer();
@@ -83411,6 +83965,7 @@ var Thread = class {
83411
83965
  #subprocess = null;
83412
83966
  #asyncQueue = Promise.resolve();
83413
83967
  #disposed = false;
83968
+ #stateChangeListeners = /* @__PURE__ */ new Set();
83414
83969
  #permissionQueue = [];
83415
83970
  #sendControlMessage;
83416
83971
  #streamDeltaSinks = /* @__PURE__ */ new Set();
@@ -83496,7 +84051,8 @@ var Thread = class {
83496
84051
  onInterrupt: () => this.#handleInterrupt(),
83497
84052
  onClose: () => this.#handleClose(),
83498
84053
  onForceKill: () => this.#handleForceKill(),
83499
- log: config2.log
84054
+ log: config2.log,
84055
+ onSessionStateChange: (newState, prevState) => this.#notifyStateChange(newState, prevState)
83500
84056
  },
83501
84057
  initialSnapshot
83502
84058
  );
@@ -83507,6 +84063,21 @@ var Thread = class {
83507
84063
  get state() {
83508
84064
  return this.#manager.state;
83509
84065
  }
84066
+ /**
84067
+ * Subscribe to session state changes. The listener is called with the new
84068
+ * and previous SessionState whenever a transition occurs.
84069
+ *
84070
+ * Dispatch is microtask-deferred (Invariant #11) to prevent re-entry into
84071
+ * wasm/loro and other synchronous event emitters.
84072
+ *
84073
+ * Returns an unsubscribe function.
84074
+ */
84075
+ onStateChange(listener) {
84076
+ this.#stateChangeListeners.add(listener);
84077
+ return () => {
84078
+ this.#stateChangeListeners.delete(listener);
84079
+ };
84080
+ }
83510
84081
  get sessionId() {
83511
84082
  return this.#ownSessionId ?? this.#manager.sessionId;
83512
84083
  }
@@ -83618,6 +84189,7 @@ var Thread = class {
83618
84189
  this.#permissionHandler.denyAllPending("Thread disposed");
83619
84190
  this.#permissionQueue.length = 0;
83620
84191
  this.#streamDeltaSinks.clear();
84192
+ this.#stateChangeListeners.clear();
83621
84193
  this.#manager.dispose();
83622
84194
  await this.#asyncQueue;
83623
84195
  this.#subprocess?.close();
@@ -83713,6 +84285,17 @@ var Thread = class {
83713
84285
  /** ------------------------------------------------------------------ */
83714
84286
  /** Session manager callbacks */
83715
84287
  /** ------------------------------------------------------------------ */
84288
+ /**
84289
+ * Dispatches state-change notifications to all registered listeners via
84290
+ * queueMicrotask so listeners cannot synchronously re-enter the session
84291
+ * manager or wasm-backed libraries (Invariant #11).
84292
+ */
84293
+ #notifyStateChange(newState, prevState) {
84294
+ if (this.#stateChangeListeners.size === 0) return;
84295
+ for (const listener of this.#stateChangeListeners) {
84296
+ queueMicrotask(() => listener(newState, prevState));
84297
+ }
84298
+ }
83716
84299
  #handleStatusChange(status) {
83717
84300
  this.#config.log({
83718
84301
  event: "thread_status_changed",
@@ -83783,7 +84366,7 @@ ${conversationReplay}` : conversationReplay;
83783
84366
  threadId: this.#config.threadId,
83784
84367
  error: msg
83785
84368
  });
83786
- this.#manager.notifySpawnFailed(msg);
84369
+ this.#manager.notifySpawnFailed(msg, this.#lastSpawnInitialContent ?? void 0);
83787
84370
  });
83788
84371
  }
83789
84372
  async #applySettingsToSubprocess() {
@@ -84131,9 +84714,13 @@ ${conversationReplay}` : conversationReplay;
84131
84714
  model: meta?.model ?? null,
84132
84715
  reasoningEffort: meta?.reasoningEffort ?? null,
84133
84716
  permissionMode: meta?.permissionMode ?? null,
84134
- ...meta?.isSynthetic && { isSynthetic: true }
84717
+ ...meta?.isSynthetic && { isSynthetic: true },
84718
+ ...meta?.correlationId && { correlationId: meta.correlationId }
84135
84719
  });
84136
84720
  this.#callbacks.onMessageStored?.(echoSeqNo, echoMsgId, "human", event.sdkUuid);
84721
+ if (meta?.correlationId) {
84722
+ this.#callbacks.onUserMessageConfirmed?.(meta.correlationId, event.sdkUuid);
84723
+ }
84137
84724
  break;
84138
84725
  }
84139
84726
  case "assistant_message": {
@@ -85463,39 +86050,6 @@ function buildIncrementalMessage(uri, current2, incremental, name, now) {
85463
86050
  };
85464
86051
  }
85465
86052
 
85466
- // src/services/roi/inject-shipyard-trailer.ts
85467
- function buildDefaultTrailerInjectDeps() {
85468
- return {
85469
- readCommitMessage: async (cwd) => runWithTimeout("git", ["log", "-1", "--pretty=%B"], cwd, 5e3),
85470
- amendCommit: async (cwd, message) => {
85471
- await runWithTimeout("git", ["commit", "--amend", "--no-edit", "-m", message], cwd, 15e3);
85472
- },
85473
- getHeadSha: async (cwd) => (await runWithTimeout("git", ["rev-parse", "HEAD"], cwd, 5e3)).trim(),
85474
- getCurrentBranch: async (cwd) => (await runWithTimeout("git", ["rev-parse", "--abbrev-ref", "HEAD"], cwd, 5e3)).trim(),
85475
- getRepoSlug: async (cwd) => {
85476
- const url = (await runWithTimeout("git", ["config", "--get", "remote.origin.url"], cwd, 5e3).catch(
85477
- () => ""
85478
- )).trim();
85479
- const match2 = url.match(/[:/]([^/:]+\/[^/:]+?)(\.git)?\s*$/);
85480
- return match2?.[1] ?? "";
85481
- }
85482
- };
85483
- }
85484
- async function injectShipyardTrailer(ctx, deps) {
85485
- const original = await deps.readCommitMessage(ctx.cwd);
85486
- if (hasShipyardTrailer(original)) {
85487
- return { injected: false, reason: "already_trailered" };
85488
- }
85489
- const newMessage = appendTrailerToMessage(original, ctx.trailer);
85490
- await deps.amendCommit(ctx.cwd, newMessage);
85491
- const [commitSha, branch, repo] = await Promise.all([
85492
- deps.getHeadSha(ctx.cwd),
85493
- deps.getCurrentBranch(ctx.cwd),
85494
- deps.getRepoSlug(ctx.cwd)
85495
- ]);
85496
- return { injected: true, commitSha, branch, repo };
85497
- }
85498
-
85499
86053
  // src/services/task-resource-resolver.ts
85500
86054
  var TASK_URI_PREFIX = "shipyard://task/";
85501
86055
  function buildTaskResourceUri(taskId) {
@@ -88518,17 +89072,6 @@ function trackPlanFileCreation(content, onPlanFile) {
88518
89072
  }
88519
89073
  }
88520
89074
  }
88521
- function detectCommitInBashToolResults(content) {
88522
- const commitPattern = /(?:^|\n)\[[\w][^[\]]*\s[0-9a-f]{7,40}\]/;
88523
- const graphitePattern = /Amended.*commit|Committing changes to/i;
88524
- for (const block2 of content) {
88525
- if (block2.type !== "tool_result" || block2.isError) continue;
88526
- if (commitPattern.test(block2.content) || graphitePattern.test(block2.content)) {
88527
- return true;
88528
- }
88529
- }
88530
- return false;
88531
- }
88532
89075
 
88533
89076
  // src/services/token-counter.ts
88534
89077
  import { execFile as execFile7 } from "child_process";
@@ -88870,9 +89413,18 @@ function replayTurnStats(lastTurnStats, send) {
88870
89413
  }
88871
89414
 
88872
89415
  // src/services/task/orchestrator/task.ts
89416
+ function hasActiveCollaborators(participants) {
89417
+ return participants.length > 0;
89418
+ }
88873
89419
  function shouldClearPersistence(status) {
88874
89420
  return status === "completed" || status === "canceled";
88875
89421
  }
89422
+ function classifyRoiTransition(nextStatus, roiEverRan) {
89423
+ if (nextStatus === "completed" || nextStatus === "canceled") return "emit-ended";
89424
+ if (nextStatus === "input_required" && roiEverRan) return "emit-ended";
89425
+ if (nextStatus === "in_progress") return "reset-cycle";
89426
+ return "nothing";
89427
+ }
88876
89428
  var Task = class {
88877
89429
  #deps;
88878
89430
  #mainThread;
@@ -88914,19 +89466,17 @@ var Task = class {
88914
89466
  #pendingPostCompactStats = null;
88915
89467
  #pendingPostCompactTurnStats = null;
88916
89468
  /** ROI: wall-clock start of this task lifetime, used for durationMs on task_ended. */
88917
- #roiStartedAtMs = Date.now();
89469
+ #roiStartedAtMs;
88918
89470
  /** ROI: tracks the most recent status for finalStatus on task_ended. */
88919
89471
  #roiLastStatus = "open";
88920
89472
  /** ROI: flips true the first time status transitions to in_progress (i.e. agent ran). */
88921
89473
  #roiEverRan = false;
88922
- /** ROI: guard against duplicate task_ended emission. */
88923
- #roiEndedEmitted = false;
89474
+ /** ROI: guard against duplicate task_ended emission for the current run cycle. */
89475
+ #roiEndedEmittedForCurrentCycle = false;
88924
89476
  /** ROI: counts agent turns (incremented on each turn_complete). */
88925
89477
  #roiTurnCount = 0;
88926
- /** ROI: serializes trailer injections so concurrent tool_result_echo events don't race the git amend. */
89478
+ /** ROI: serializes commit-scan triggers so concurrent tool_result_echo events don't race the git amend inside scanAndAttributeCommits. */
88927
89479
  #trailerInjectionChain = Promise.resolve();
88928
- /** ROI: trailer injection deps (overridable for tests). */
88929
- #trailerInjectDeps = buildDefaultTrailerInjectDeps();
88930
89480
  /** MCP status polling (delegated to McpPoller) */
88931
89481
  #mcpPoller;
88932
89482
  /** Telemetry (delegated to TaskTelemetry) */
@@ -88949,6 +89499,8 @@ var Task = class {
88949
89499
  this.#lastTurnStats = deps.initialTurnStats ?? null;
88950
89500
  this.#tokenCountResult = deps.initialTokenCount ?? null;
88951
89501
  this.#hydrationPromise = deps.hydrationPromise ?? null;
89502
+ this.#roiStartedAtMs = deps.taskCreatedAt;
89503
+ this.#roiTurnCount = deps.initialTurnCount;
88952
89504
  this.#telemetry = new TaskTelemetry({
88953
89505
  taskId: deps.taskId,
88954
89506
  metricsCollector: deps.metricsCollector,
@@ -88978,10 +89530,16 @@ var Task = class {
88978
89530
  settings: mergedSettings,
88979
89531
  participantId: firstMeta?.participantId ?? this.#deps.humanParticipantId,
88980
89532
  senderName: firstMeta?.senderDisplayName ?? this.#deps.ownerDisplayName ?? null,
88981
- originalContent: content
89533
+ originalContent: content,
89534
+ correlationIds: metadata.map((m2) => m2.correlationId).filter((x2) => !!x2)
88982
89535
  });
88983
89536
  this.#mainThread.handleUserMessage(content, mergedSettings);
88984
89537
  },
89538
+ onBatchForwarded: (correlationIds) => {
89539
+ for (const id of correlationIds) {
89540
+ this.#deps.onForwardedAck(id);
89541
+ }
89542
+ },
88985
89543
  log: deps.log,
88986
89544
  persistence: deps.collabQueuePersistence,
88987
89545
  queueKey: mainQueueKey(deps.taskId)
@@ -89210,15 +89768,43 @@ var Task = class {
89210
89768
  * rather than a real user message. Mark it so the browser
89211
89769
  * hides it via assembleMessages' isSynthetic filter.
89212
89770
  */
89213
- isSynthetic: meta === void 0
89771
+ isSynthetic: meta === void 0,
89772
+ correlationId: meta?.correlationIds[0]
89214
89773
  };
89215
89774
  },
89775
+ onUserMessageConfirmed: (correlationId, sdkUuid) => {
89776
+ const currentMeta = this.#pendingMessageMeta[0];
89777
+ const idsToAck = currentMeta?.correlationIds ?? [correlationId];
89778
+ if (sdkUuid) {
89779
+ const [primary, ...secondary] = idsToAck;
89780
+ const primaryId = primary ?? correlationId;
89781
+ this.#deps.store.stampSdkUuid(
89782
+ this.#deps.channelId,
89783
+ primaryId,
89784
+ sdkUuid,
89785
+ secondary.length > 0 ? secondary : void 0
89786
+ ).catch((err) => {
89787
+ this.#deps.log({
89788
+ event: "stamp_sdk_uuid_failed",
89789
+ taskId: this.#deps.taskId,
89790
+ correlationId: primaryId,
89791
+ error: err instanceof Error ? err.message : String(err)
89792
+ });
89793
+ });
89794
+ }
89795
+ for (const id of idsToAck) {
89796
+ this.#deps.onConfirmedAck(id);
89797
+ }
89798
+ },
89216
89799
  onBeforeSpawn: (content) => this.#handleBeforeSpawn(content),
89217
89800
  onBeforeFlush: (messages) => this.#handleBeforeFlush(messages),
89218
89801
  buildCanUseTool: () => this.#buildCanUseTool()
89219
89802
  }
89220
89803
  );
89221
89804
  this.#mainThread.initAdoptedCanUseTool();
89805
+ this.#collabQueue.wireThreadStateSubscription(
89806
+ (listener) => this.#mainThread.onStateChange(listener)
89807
+ );
89222
89808
  this.#sideThreads = new SideThreadRegistry({
89223
89809
  taskId: deps.taskId,
89224
89810
  dataDir: deps.dataDir,
@@ -89244,13 +89830,22 @@ var Task = class {
89244
89830
  })
89245
89831
  );
89246
89832
  }
89247
- emitTaskStarted(deps.metricsCollector, {
89248
- taskId: deps.taskId,
89249
- userId: deps.userId,
89250
- activeTaskCount: deps.getActiveTaskCount(),
89251
- mode: deps.mode,
89252
- timestamp: this.#roiStartedAtMs
89253
- });
89833
+ if (!deps.initialRoiStartedEmitted) {
89834
+ emitTaskStarted(deps.metricsCollector, {
89835
+ taskId: deps.taskId,
89836
+ userId: deps.userId,
89837
+ activeTaskCount: deps.getActiveTaskCount(),
89838
+ mode: deps.mode,
89839
+ timestamp: this.#roiStartedAtMs
89840
+ });
89841
+ deps.persistRoiStarted().catch((err) => {
89842
+ deps.log({
89843
+ event: "roi_persist_started_failed",
89844
+ taskId: deps.taskId,
89845
+ error: err instanceof Error ? err.message : String(err)
89846
+ });
89847
+ });
89848
+ }
89254
89849
  }
89255
89850
  get state() {
89256
89851
  return this.#mainThread.state;
@@ -89258,23 +89853,28 @@ var Task = class {
89258
89853
  get sessionId() {
89259
89854
  return this.#mainThread.sessionId;
89260
89855
  }
89856
+ /** Current working directory. May be undefined during early bootstrap before EnterWorktree. */
89857
+ get cwd() {
89858
+ return this.#cwd;
89859
+ }
89261
89860
  get latestSettings() {
89262
89861
  return this.#latestSettings;
89263
89862
  }
89264
89863
  get mainThread() {
89265
89864
  return this.#mainThread;
89266
89865
  }
89267
- handleUserMessage(content, settings, participantId, senderDisplayName) {
89866
+ handleUserMessage(content, settings, participantId, senderDisplayName, correlationId) {
89268
89867
  if (settings) {
89269
89868
  this.#latestSettings = { ...this.#latestSettings, ...settings };
89270
89869
  }
89271
89870
  const participants = this.#deps.getCollabParticipants();
89272
- if (participants.length > 0) {
89871
+ if (hasActiveCollaborators(participants)) {
89273
89872
  this.#collabQueue.enqueue({
89274
89873
  content,
89275
89874
  settings,
89276
89875
  participantId: participantId ?? this.#deps.humanParticipantId,
89277
- senderDisplayName: senderDisplayName ?? void 0
89876
+ senderDisplayName: senderDisplayName ?? void 0,
89877
+ correlationId
89278
89878
  });
89279
89879
  return;
89280
89880
  }
@@ -89282,23 +89882,100 @@ var Task = class {
89282
89882
  settings: { ...this.#latestSettings },
89283
89883
  participantId: participantId ?? this.#deps.humanParticipantId,
89284
89884
  senderName: senderDisplayName ?? this.#deps.ownerDisplayName ?? null,
89285
- originalContent: content
89885
+ originalContent: content,
89886
+ correlationIds: correlationId ? [correlationId] : []
89286
89887
  });
89287
89888
  this.#mainThread.handleUserMessage(content, settings);
89889
+ if (correlationId) {
89890
+ this.#deps.onForwardedAck(correlationId);
89891
+ }
89288
89892
  }
89289
89893
  /**
89290
89894
  * Restore in-flight collab batches from persistence after daemon
89291
89895
  * restart. Replays entries through the main queue so per-peer
89292
89896
  * timers re-initialize and missed messages reach the agent on
89293
89897
  * the next flush.
89898
+ *
89899
+ * Returns the set of correlationIds whose JSONL rows were claimed by this
89900
+ * replay so rehydrateUnpushedMessages can skip them and avoid double-sending.
89294
89901
  */
89295
89902
  async rehydrateCollabQueue() {
89296
89903
  const persistence = this.#deps.collabQueuePersistence;
89297
- if (!persistence) return;
89904
+ if (!persistence) return /* @__PURE__ */ new Set();
89298
89905
  const key = mainQueueKey(this.#deps.taskId);
89299
89906
  const entries = await persistence.read(key);
89300
- if (entries.length === 0) return;
89907
+ if (entries.length === 0) return /* @__PURE__ */ new Set();
89301
89908
  await this.#collabQueue.rehydrate(entries);
89909
+ const messages = await this.#deps.store.getMessages(this.#deps.channelId);
89910
+ return matchCollabQueueCorrelationIds(messages, entries);
89911
+ }
89912
+ /**
89913
+ * Restore in-flight collab batches for a side thread from persistence
89914
+ * after daemon restart. Mirrors rehydrateCollabQueue for thread-scoped queues.
89915
+ *
89916
+ * Returns false when the side thread is not in the registry — signals orphan
89917
+ * so caller clears the file, mirroring the main-queue orphan-clear path.
89918
+ * Lazily initializes the thread's CollabMessageQueue using the same
89919
+ * construction path as the write side (handleThreadUserMessage).
89920
+ */
89921
+ async rehydrateThreadQueue(threadId) {
89922
+ const persistence = this.#deps.collabQueuePersistence;
89923
+ if (!persistence || !this.#sideThreads.get(threadId)) return false;
89924
+ const key = threadQueueKey(this.#deps.taskId, threadId);
89925
+ const entries = await persistence.read(key);
89926
+ if (entries.length === 0) return true;
89927
+ let queue = this.#threadQueues.get(threadId);
89928
+ if (!queue) {
89929
+ const sideThread = this.#sideThreads.get(threadId);
89930
+ queue = new CollabMessageQueue({
89931
+ getCollabParticipants: this.#deps.getCollabParticipants,
89932
+ forwardBatch: (batchContent, metadata) => {
89933
+ this.#forwardThreadBatch(threadId, batchContent, metadata);
89934
+ },
89935
+ log: this.#deps.log,
89936
+ persistence: this.#deps.collabQueuePersistence,
89937
+ queueKey: threadQueueKey(this.#deps.taskId, threadId),
89938
+ subscribeToThreadState: sideThread ? (listener) => sideThread.onStateChange(listener) : void 0
89939
+ });
89940
+ this.#threadQueues.set(threadId, queue);
89941
+ }
89942
+ await queue.rehydrate(entries);
89943
+ return true;
89944
+ }
89945
+ /**
89946
+ * Sub-phase 7b: Rehydrate persisted-but-unpushed user messages after daemon restart.
89947
+ *
89948
+ * Scans the JSONL store for rows where correlationId is set but sdkUuid is null
89949
+ * (persisted but not confirmed by SDK). Re-injects them as pushMessage content
89950
+ * so the next subprocess spawn receives them in order.
89951
+ *
89952
+ * Pass excludeCorrelationIds to skip messages already handled by rehydrateCollabQueue,
89953
+ * preventing double-send of the same message to the subprocess.
89954
+ *
89955
+ * Returns the number of messages re-injected.
89956
+ */
89957
+ async rehydrateUnpushedMessages(excludeCorrelationIds) {
89958
+ const messages = await this.#deps.store.getMessages(this.#deps.channelId);
89959
+ const unpushed = messages.filter(
89960
+ (m2) => m2.senderKind === "human" && m2.correlationId && !m2.sdkUuid && !(excludeCorrelationIds && m2.correlationId && excludeCorrelationIds.has(m2.correlationId))
89961
+ );
89962
+ if (unpushed.length === 0) return 0;
89963
+ for (const msg of unpushed) {
89964
+ this.#pendingMessageMeta.push({
89965
+ settings: { ...this.#latestSettings },
89966
+ participantId: msg.participantId,
89967
+ senderName: null,
89968
+ originalContent: msg.content,
89969
+ correlationIds: msg.correlationId ? [msg.correlationId, ...msg.batchCorrelationIds ?? []] : []
89970
+ });
89971
+ this.#mainThread.handleUserMessage(msg.content);
89972
+ }
89973
+ this.#deps.log({
89974
+ event: "unpushed_messages_rehydrated",
89975
+ taskId: this.#deps.taskId,
89976
+ count: unpushed.length
89977
+ });
89978
+ return unpushed.length;
89302
89979
  }
89303
89980
  /**
89304
89981
  * Route a thread user message through collab micro-batching when
@@ -89326,7 +90003,8 @@ var Task = class {
89326
90003
  },
89327
90004
  log: this.#deps.log,
89328
90005
  persistence: this.#deps.collabQueuePersistence,
89329
- queueKey: threadQueueKey(this.#deps.taskId, threadId)
90006
+ queueKey: threadQueueKey(this.#deps.taskId, threadId),
90007
+ subscribeToThreadState: (listener) => sideThread.onStateChange(listener)
89330
90008
  });
89331
90009
  this.#threadQueues.set(threadId, queue);
89332
90010
  }
@@ -89342,7 +90020,8 @@ var Task = class {
89342
90020
  settings: settings ?? this.#latestSettings,
89343
90021
  participantId: participantId ?? this.#deps.humanParticipantId,
89344
90022
  senderName: senderDisplayName ?? null,
89345
- originalContent: content
90023
+ originalContent: content,
90024
+ correlationIds: []
89346
90025
  });
89347
90026
  sideThread.handleUserMessage(content, settings);
89348
90027
  }
@@ -89855,7 +90534,8 @@ var Task = class {
89855
90534
  settings: mergedSettings,
89856
90535
  participantId: firstMeta?.participantId ?? this.#deps.humanParticipantId,
89857
90536
  senderName: firstMeta?.senderDisplayName ?? this.#deps.ownerDisplayName ?? null,
89858
- originalContent: content
90537
+ originalContent: content,
90538
+ correlationIds: metadata.map((m2) => m2.correlationId).filter((x2) => !!x2)
89859
90539
  });
89860
90540
  sideThread.handleUserMessage(content, mergedSettings);
89861
90541
  }
@@ -89872,11 +90552,14 @@ var Task = class {
89872
90552
  if (!arr || arr.length === 0) return void 0;
89873
90553
  return arr.shift();
89874
90554
  }
89875
- async #handleBeforeSpawn(initialContent) {
90555
+ async #awaitHydration() {
89876
90556
  if (this.#hydrationPromise) {
89877
90557
  await this.#hydrationPromise;
89878
90558
  this.#hydrationPromise = null;
89879
90559
  }
90560
+ }
90561
+ async #handleBeforeSpawn(initialContent) {
90562
+ await this.#awaitHydration();
89880
90563
  await this.#ensureCompactContextFromStore();
89881
90564
  if (this.#needsPromotionContext) {
89882
90565
  this.#needsPromotionContext = false;
@@ -89966,6 +90649,7 @@ Use this context to maintain continuity. You have already done this work \u2014
89966
90649
  return syntheticPrepend.length > 0 ? [...syntheticPrepend, ...annotated] : annotated;
89967
90650
  }
89968
90651
  async #handleBeforeFlush(messages) {
90652
+ await this.#awaitHydration();
89969
90653
  this.#rewindCheckpoint.enqueueSpawnCheckpoint();
89970
90654
  const history2 = await this.#deps.store.getMessages(this.#deps.channelId);
89971
90655
  const result = [];
@@ -90005,19 +90689,29 @@ Use this context to maintain continuity. You have already done this work \u2014
90005
90689
  }
90006
90690
  return null;
90007
90691
  }
90008
- #handleStatusChange(status) {
90009
- if (!isTaskStatus(status)) return;
90010
- let effectiveStatus = status;
90692
+ /**
90693
+ * Scheduled (cron) tasks have no interactive user — the first agent turn
90694
+ * is the complete run. When the session reaches `input_required` (i.e.
90695
+ * the agent finished and is waiting for the next user message), auto-
90696
+ * complete the task and tear down the session.
90697
+ *
90698
+ * The guard prevents re-entry: stop() triggers stopping -> resumable_idle
90699
+ * which emits another `input_required`, but by then we've already written
90700
+ * `completed`. Subsequent status callbacks after auto-complete are dropped.
90701
+ *
90702
+ * Returns the effective status to use, or null if the caller should return early.
90703
+ */
90704
+ #resolveScheduledStatus(status) {
90011
90705
  if (this.#deps.scheduleId && this.#scheduledAutoCompleted) {
90012
90706
  if (status === "in_progress") {
90013
90707
  this.#scheduledAutoCompleted = false;
90014
90708
  } else {
90015
- return;
90709
+ return null;
90016
90710
  }
90017
90711
  }
90018
90712
  if (status === "input_required" && this.#deps.scheduleId && !this.#scheduledAutoCompleted) {
90019
90713
  this.#scheduledAutoCompleted = true;
90020
- effectiveStatus = this.#explicitlyStopped ? "canceled" : "completed";
90714
+ const effective = this.#explicitlyStopped ? "canceled" : "completed";
90021
90715
  this.#deps.log({
90022
90716
  event: this.#explicitlyStopped ? "scheduled_task_stopped" : "scheduled_task_auto_completed",
90023
90717
  taskId: this.#deps.taskId,
@@ -90026,7 +90720,14 @@ Use this context to maintain continuity. You have already done this work \u2014
90026
90720
  if (!this.#explicitlyStopped) {
90027
90721
  this.#mainThread.stop();
90028
90722
  }
90723
+ return effective;
90029
90724
  }
90725
+ return status;
90726
+ }
90727
+ #handleStatusChange(status) {
90728
+ if (!isTaskStatus(status)) return;
90729
+ const effectiveStatus = this.#resolveScheduledStatus(status);
90730
+ if (effectiveStatus === null) return;
90030
90731
  this.#deps.log({
90031
90732
  event: "task_status_changed",
90032
90733
  taskId: this.#deps.taskId,
@@ -90037,6 +90738,19 @@ Use this context to maintain continuity. You have already done this work \u2014
90037
90738
  status: effectiveStatus
90038
90739
  });
90039
90740
  this.#trackRoiStatus(effectiveStatus);
90741
+ const roiAction = classifyRoiTransition(effectiveStatus, this.#roiEverRan);
90742
+ switch (roiAction) {
90743
+ case "emit-ended":
90744
+ this.#emitTaskEndedOnce();
90745
+ break;
90746
+ case "reset-cycle":
90747
+ this.#roiEndedEmittedForCurrentCycle = false;
90748
+ break;
90749
+ case "nothing":
90750
+ break;
90751
+ default:
90752
+ assertNever(roiAction);
90753
+ }
90040
90754
  this.#deps.writeTaskStatus(effectiveStatus);
90041
90755
  if (shouldClearPersistence(effectiveStatus)) {
90042
90756
  this.#rewindCheckpoint.cleanup(this.#cwd).catch((err) => {
@@ -90126,7 +90840,7 @@ Use this context to maintain continuity. You have already done this work \u2014
90126
90840
  this.#handleWorktreeToolResults(event.content);
90127
90841
  this.#subagentManager.handleSubagentToolResults(event.content);
90128
90842
  this.#trackPlanFileCreation(event.content);
90129
- this.#maybeInjectShipyardTrailer(event.content);
90843
+ this.#triggerCommitScan();
90130
90844
  break;
90131
90845
  case "api_usage_snapshot":
90132
90846
  this.#lastApiUsage = {
@@ -90227,6 +90941,13 @@ Use this context to maintain continuity. You have already done this work \u2014
90227
90941
  }
90228
90942
  #handleTurnComplete(event) {
90229
90943
  this.#roiTurnCount += 1;
90944
+ this.#deps.onTurnComplete().catch((err) => {
90945
+ this.#deps.log({
90946
+ event: "roi_persist_turn_count_failed",
90947
+ taskId: this.#deps.taskId,
90948
+ error: err instanceof Error ? err.message : String(err)
90949
+ });
90950
+ });
90230
90951
  const prevResult = this.#lastTurnResult;
90231
90952
  this.#lastTurnResult = event.result;
90232
90953
  this.#deps.log({
@@ -90382,8 +91103,8 @@ Use this context to maintain continuity. You have already done this work \u2014
90382
91103
  if (status === "in_progress") this.#roiEverRan = true;
90383
91104
  }
90384
91105
  #emitTaskEndedOnce() {
90385
- if (this.#roiEndedEmitted) return;
90386
- this.#roiEndedEmitted = true;
91106
+ if (this.#roiEndedEmittedForCurrentCycle) return;
91107
+ this.#roiEndedEmittedForCurrentCycle = true;
90387
91108
  const finalStatus = this.#roiLastStatus === "completed" ? "completed" : this.#roiLastStatus === "input_required" ? "input_required" : "canceled";
90388
91109
  emitTaskEnded(this.#deps.metricsCollector, {
90389
91110
  taskId: this.#deps.taskId,
@@ -90398,45 +91119,44 @@ Use this context to maintain continuity. You have already done this work \u2014
90398
91119
  this.#deps.persistAbandonedAt(Date.now());
90399
91120
  }
90400
91121
  }
90401
- #maybeInjectShipyardTrailer(content) {
90402
- if (!detectCommitInBashToolResults(content)) return;
91122
+ #triggerCommitScan() {
90403
91123
  const cwd = this.#cwd;
90404
91124
  if (!cwd) return;
90405
- const sessionId = this.sessionId ?? `pre-session-${this.#deps.taskId}`;
90406
- const trailer = {
90407
- version: TRAILER_SCHEMA_VERSION,
90408
- taskId: this.#deps.taskId,
90409
- sessionId,
90410
- model: this.#latestSettings.model ?? "unknown",
90411
- tokens: this.#costBaseline.totalOutputTokens,
90412
- costUsd: this.#costBaseline.totalCostUsd,
90413
- turnCount: this.#roiTurnCount,
90414
- attributionType: "originated",
90415
- clientVersion: "shipyard-daemon"
90416
- };
90417
91125
  this.#trailerInjectionChain = this.#trailerInjectionChain.then(async () => {
90418
91126
  try {
90419
- const result = await injectShipyardTrailer({ cwd, trailer }, this.#trailerInjectDeps);
90420
- if (!result.injected || !result.commitSha || !result.repo || !result.branch) return;
90421
- emitCommitAttributed(this.#deps.metricsCollector, {
90422
- taskId: this.#deps.taskId,
90423
- userId: this.#deps.userId,
90424
- commitSha: result.commitSha,
90425
- repo: result.repo,
90426
- branch: result.branch,
90427
- model: trailer.model,
90428
- tokens: trailer.tokens,
90429
- costUsd: trailer.costUsd,
90430
- turnCount: trailer.turnCount,
90431
- attributionType: "originated"
90432
- });
91127
+ const state = await this.#deps.getCommitScanState();
91128
+ await scanAndAttributeCommits(
91129
+ {
91130
+ cwd,
91131
+ taskId: this.#deps.taskId,
91132
+ userId: this.#deps.userId,
91133
+ lastSeenSha: state.lastSeenSha,
91134
+ attributedCommitShas: state.attributedCommitShas
91135
+ },
91136
+ {
91137
+ metricsCollector: this.#deps.metricsCollector,
91138
+ log: this.#deps.log,
91139
+ amendHead: true,
91140
+ setLastCommitScanSha: (_taskId, sha) => this.#deps.setLastCommitScanSha(sha),
91141
+ addAttributedCommitSha: (_taskId, sha) => this.#deps.addAttributedCommitSha(sha),
91142
+ injectTrailer: injectShipyardTrailer,
91143
+ getAttributionPayload: () => ({
91144
+ model: this.#latestSettings.model ?? "unknown",
91145
+ tokens: this.#costBaseline.totalOutputTokens,
91146
+ costUsd: this.#costBaseline.totalCostUsd,
91147
+ turnCount: this.#roiTurnCount,
91148
+ sessionId: this.sessionId ?? `pre-session-${this.#deps.taskId}`
91149
+ })
91150
+ }
91151
+ );
90433
91152
  } catch (err) {
90434
91153
  this.#deps.log({
90435
- event: "roi_trailer_injection_failed",
91154
+ event: "roi_commit_scan_failed",
90436
91155
  taskId: this.#deps.taskId,
90437
91156
  error: err instanceof Error ? err.message : String(err)
90438
91157
  });
90439
91158
  }
91159
+ }).catch(() => {
90440
91160
  });
90441
91161
  }
90442
91162
  applyOverlay(overlay) {
@@ -90552,6 +91272,27 @@ Use this context to maintain continuity. You have already done this work \u2014
90552
91272
  replayTurnStats(this.#lastTurnStats, send);
90553
91273
  }
90554
91274
  };
91275
+ function matchCollabQueueCorrelationIds(messages, queueEntries) {
91276
+ const unpushedHuman = messages.filter(
91277
+ (m2) => m2.senderKind === "human" && m2.correlationId && !m2.sdkUuid
91278
+ );
91279
+ const claimedIds = /* @__PURE__ */ new Set();
91280
+ let queueIdx = 0;
91281
+ for (const msg of unpushedHuman) {
91282
+ if (queueIdx >= queueEntries.length) break;
91283
+ const entry = queueEntries[queueIdx];
91284
+ if (entry && JSON.stringify(msg.content) === JSON.stringify(entry.content)) {
91285
+ if (msg.correlationId) {
91286
+ claimedIds.add(msg.correlationId);
91287
+ for (const secondary of msg.batchCorrelationIds ?? []) {
91288
+ claimedIds.add(secondary);
91289
+ }
91290
+ }
91291
+ queueIdx++;
91292
+ }
91293
+ }
91294
+ return claimedIds;
91295
+ }
90555
91296
 
90556
91297
  // src/services/task/manager/task-manager-diff.ts
90557
91298
  async function rewindTask(params) {
@@ -90894,6 +91635,35 @@ function findSdkUuidForSeqNoInTask(tasks, taskId, seqNo) {
90894
91635
  return tasks.get(taskId)?.orchestrator.findSdkUuidForSeqNo(seqNo) ?? null;
90895
91636
  }
90896
91637
 
91638
+ // src/services/task/manager/task-manager-template.ts
91639
+ function buildInitialOverlayFromTemplate(template, now) {
91640
+ if (!template || template.items.length === 0) return void 0;
91641
+ const userTasks = template.items.map((item2) => ({
91642
+ id: item2.id,
91643
+ subject: item2.content,
91644
+ description: item2.description,
91645
+ status: "pending",
91646
+ blocks: [],
91647
+ blockedBy: item2.deps,
91648
+ createdAt: now,
91649
+ updatedAt: now
91650
+ }));
91651
+ return { ...DEFAULT_TASK_OVERLAY, userTasks };
91652
+ }
91653
+ function applyInitialOverlayToOrchestrator(args) {
91654
+ const { taskId, initialOverlay, orchestratorRef, isTaskRegistered } = args;
91655
+ if (orchestratorRef && isTaskRegistered(taskId)) {
91656
+ orchestratorRef.applyOverlay(initialOverlay);
91657
+ args.notifyTaskResourceChange(taskId);
91658
+ return;
91659
+ }
91660
+ args.log({
91661
+ event: "initial_overlay_skipped",
91662
+ taskId,
91663
+ reason: orchestratorRef ? "task_removed" : "orchestrator_not_registered"
91664
+ });
91665
+ }
91666
+
90897
91667
  // src/services/task/manager/task-manager.ts
90898
91668
  var TaskManager = class {
90899
91669
  #deps;
@@ -90906,6 +91676,18 @@ var TaskManager = class {
90906
91676
  #onRateLimitEvent = null;
90907
91677
  #overlayQueues = /* @__PURE__ */ new Map();
90908
91678
  #preWarmManager = null;
91679
+ /**
91680
+ * Per-task confirmed-ack emitter. Set when a message channel channel opens for a task,
91681
+ * cleared when it closes. Called by Task.onConfirmedAck to route 'confirmed' acks back
91682
+ * to the originating peer's message channel handler.
91683
+ */
91684
+ #confirmedAckEmitters = /* @__PURE__ */ new Map();
91685
+ /**
91686
+ * Per-task forwarded-ack emitter. Set when a message channel opens for a task,
91687
+ * cleared when it closes. Called by Task.onForwardedAck to route 'forwarded' acks
91688
+ * back to the originating peer's message channel handler.
91689
+ */
91690
+ #forwardedAckEmitters = /* @__PURE__ */ new Map();
90909
91691
  constructor(deps) {
90910
91692
  this.#deps = deps;
90911
91693
  }
@@ -91102,6 +91884,28 @@ var TaskManager = class {
91102
91884
  subscribeThreadStreamDelta(taskId, threadId, listener) {
91103
91885
  return this.#tasks.get(taskId)?.orchestrator.subscribeSideThreadStreamDelta(threadId, listener) ?? null;
91104
91886
  }
91887
+ /**
91888
+ * Register a confirmed-ack emitter for a task's message channel.
91889
+ * Returns an unregister function to call when the channel closes.
91890
+ * When the task's SDK user_message_echo fires for a correlationId,
91891
+ * the emitter is called so the channel can send stage:'confirmed'.
91892
+ */
91893
+ registerConfirmedAckEmitter(taskId, emitter) {
91894
+ this.#confirmedAckEmitters.set(taskId, emitter);
91895
+ return () => {
91896
+ if (this.#confirmedAckEmitters.get(taskId) === emitter) {
91897
+ this.#confirmedAckEmitters.delete(taskId);
91898
+ }
91899
+ };
91900
+ }
91901
+ registerForwardedAckEmitter(taskId, emitter) {
91902
+ this.#forwardedAckEmitters.set(taskId, emitter);
91903
+ return () => {
91904
+ if (this.#forwardedAckEmitters.get(taskId) === emitter) {
91905
+ this.#forwardedAckEmitters.delete(taskId);
91906
+ }
91907
+ };
91908
+ }
91105
91909
  subscribeStreamDelta(taskId, listener) {
91106
91910
  const task = this.#tasks.get(taskId);
91107
91911
  if (task) {
@@ -91197,6 +92001,8 @@ var TaskManager = class {
91197
92001
  if (this.#tasks.has(params.taskId)) return;
91198
92002
  const cwd = params.cwd;
91199
92003
  const mode = params.mode ?? "task";
92004
+ let orchestratorRef = null;
92005
+ const taskCreatedAt = Date.now();
91200
92006
  const storeAndHydrate = async () => {
91201
92007
  const template = params.templateId ? await this.#deps.templateStore.get(params.templateId) : null;
91202
92008
  if (params.templateId && !template) {
@@ -91206,26 +92012,23 @@ var TaskManager = class {
91206
92012
  templateId: params.templateId
91207
92013
  });
91208
92014
  }
91209
- const now = Date.now();
91210
- const initialOverlay = template?.items.map((item2) => ({
91211
- id: item2.id,
91212
- subject: item2.content,
91213
- description: item2.description,
91214
- status: "pending",
91215
- blocks: [],
91216
- blockedBy: item2.deps,
91217
- createdAt: now,
91218
- updatedAt: now
91219
- })) ?? [];
92015
+ const initialOverlay = buildInitialOverlayFromTemplate(template, taskCreatedAt);
91220
92016
  await this.#deps.taskStateStore.createTask({
91221
92017
  ...params,
91222
92018
  cwd,
91223
92019
  mode,
91224
92020
  appliedTemplateId: params.templateId,
91225
- initialOverlay: initialOverlay.length > 0 ? { ...DEFAULT_TASK_OVERLAY, userTasks: initialOverlay } : void 0
92021
+ initialOverlay
91226
92022
  });
91227
- if (initialOverlay.length > 0) {
91228
- this.#deps.notifyTaskResourceChange(params.taskId);
92023
+ if (initialOverlay) {
92024
+ applyInitialOverlayToOrchestrator({
92025
+ taskId: params.taskId,
92026
+ initialOverlay,
92027
+ orchestratorRef,
92028
+ isTaskRegistered: (id) => this.#tasks.has(id),
92029
+ notifyTaskResourceChange: this.#deps.notifyTaskResourceChange,
92030
+ log: this.#deps.log
92031
+ });
91229
92032
  }
91230
92033
  };
91231
92034
  const hydrationPromise = storeAndHydrate().catch((err) => {
@@ -91238,14 +92041,28 @@ var TaskManager = class {
91238
92041
  const claim = mode !== "conversation" ? this.#preWarmManager?.claim(cwd) : void 0;
91239
92042
  if (claim) {
91240
92043
  claim.subprocess.setHarnessTaskId(params.taskId);
92044
+ const resolved = this.#deps.resolveMcpServers();
92045
+ if (resolved && Object.keys(resolved).length > 0) {
92046
+ claim.subprocess.setMcpServers(resolved).catch((err) => {
92047
+ this.#deps.log({
92048
+ event: "prewarm_adoption_mcp_sync_failed",
92049
+ taskId: params.taskId,
92050
+ error: err instanceof Error ? err.message : String(err)
92051
+ });
92052
+ });
92053
+ }
91241
92054
  }
91242
92055
  const orchestrator = this.#createTask(params.taskId, params.channelId, {
91243
92056
  adoptedSubprocess: claim ?? void 0,
91244
92057
  cwd,
91245
92058
  mode,
91246
92059
  scheduleId: params.scheduleId,
91247
- hydrationPromise
92060
+ hydrationPromise,
92061
+ initialRoiStartedEmitted: false,
92062
+ taskCreatedAt,
92063
+ initialTurnCount: 0
91248
92064
  });
92065
+ orchestratorRef = orchestrator;
91249
92066
  this.#tasks.set(params.taskId, {
91250
92067
  taskId: params.taskId,
91251
92068
  channelId: params.channelId,
@@ -91273,7 +92090,10 @@ var TaskManager = class {
91273
92090
  initialTurnStats: opts.initialTurnStats,
91274
92091
  initialTokenCount: opts.initialTokenCount,
91275
92092
  initialPlanDetection: opts.initialPlanDetection,
91276
- mode
92093
+ mode,
92094
+ initialRoiStartedEmitted: opts.initialRoiStartedEmitted,
92095
+ taskCreatedAt: opts.taskCreatedAt,
92096
+ initialTurnCount: opts.initialTurnCount
91277
92097
  });
91278
92098
  this.#tasks.set(taskId, { taskId, channelId, cwd, mode, orchestrator });
91279
92099
  this.#flushPendingStreamSubs(taskId, orchestrator);
@@ -91320,7 +92140,7 @@ var TaskManager = class {
91320
92140
  getTaskMode(taskId) {
91321
92141
  return this.#tasks.get(taskId)?.mode ?? "task";
91322
92142
  }
91323
- async handleUserMessage(taskId, content, settings, cwd, participantId, senderDisplayName) {
92143
+ async handleUserMessage(taskId, content, settings, cwd, participantId, senderDisplayName, correlationId) {
91324
92144
  let task = this.#tasks.get(taskId);
91325
92145
  if (!task) {
91326
92146
  const record = await this.#deps.taskStateStore.getTask(taskId);
@@ -91337,7 +92157,10 @@ var TaskManager = class {
91337
92157
  },
91338
92158
  initialTurnStats: record.lastTurnStats,
91339
92159
  initialTokenCount: record.lastTokenCount,
91340
- initialPlanDetection: record.lastPlanDetection
92160
+ initialPlanDetection: record.lastPlanDetection,
92161
+ initialRoiStartedEmitted: record.roiStartedEmitted,
92162
+ taskCreatedAt: record.createdAt,
92163
+ initialTurnCount: record.totalTurnCount
91341
92164
  });
91342
92165
  task = this.#tasks.get(taskId);
91343
92166
  if (!task) {
@@ -91356,7 +92179,13 @@ var TaskManager = class {
91356
92179
  });
91357
92180
  });
91358
92181
  }
91359
- task.orchestrator.handleUserMessage(content, settings, participantId, senderDisplayName);
92182
+ task.orchestrator.handleUserMessage(
92183
+ content,
92184
+ settings,
92185
+ participantId,
92186
+ senderDisplayName,
92187
+ correlationId
92188
+ );
91360
92189
  this.#deps.taskStateStore.bumpActivity(taskId).catch((err) => {
91361
92190
  this.#deps.log({
91362
92191
  event: "task_state_store_bump_activity_failed",
@@ -91500,7 +92329,8 @@ var TaskManager = class {
91500
92329
  return [...this.#tasks.values()].map((t) => ({
91501
92330
  taskId: t.taskId,
91502
92331
  channelId: t.channelId,
91503
- sessionState: t.orchestrator.state
92332
+ sessionState: t.orchestrator.state,
92333
+ cwd: t.orchestrator.cwd
91504
92334
  }));
91505
92335
  }
91506
92336
  has(taskId) {
@@ -91736,7 +92566,35 @@ var TaskManager = class {
91736
92566
  mode: opts?.mode ?? "task",
91737
92567
  scheduleId: opts?.scheduleId,
91738
92568
  collabQueuePersistence: this.#deps.collabQueuePersistence,
91739
- hydrationPromise: opts?.hydrationPromise
92569
+ hydrationPromise: opts?.hydrationPromise,
92570
+ initialRoiStartedEmitted: opts?.initialRoiStartedEmitted ?? false,
92571
+ persistRoiStarted: async () => {
92572
+ await this.#deps.taskStateStore.setRoiStartedEmitted(taskId, { broadcast: false });
92573
+ },
92574
+ taskCreatedAt: opts?.taskCreatedAt ?? Date.now(),
92575
+ initialTurnCount: opts?.initialTurnCount ?? 0,
92576
+ onTurnComplete: async () => {
92577
+ await this.#deps.taskStateStore.incrementTotalTurnCount(taskId, 1, { broadcast: false });
92578
+ },
92579
+ getCommitScanState: async () => {
92580
+ const record = await this.#deps.taskStateStore.getTask(taskId);
92581
+ return {
92582
+ lastSeenSha: record?.lastCommitScanSha ?? null,
92583
+ attributedCommitShas: record?.attributedCommitShas ?? []
92584
+ };
92585
+ },
92586
+ setLastCommitScanSha: async (sha) => {
92587
+ await this.#deps.taskStateStore.setLastCommitScanSha(taskId, sha, { broadcast: false });
92588
+ },
92589
+ addAttributedCommitSha: async (sha) => {
92590
+ await this.#deps.taskStateStore.addAttributedCommitSha(taskId, sha, { broadcast: false });
92591
+ },
92592
+ onConfirmedAck: (correlationId) => {
92593
+ this.#confirmedAckEmitters.get(taskId)?.(correlationId);
92594
+ },
92595
+ onForwardedAck: (correlationId) => {
92596
+ this.#forwardedAckEmitters.get(taskId)?.(correlationId);
92597
+ }
91740
92598
  });
91741
92599
  }
91742
92600
  };
@@ -91753,6 +92611,37 @@ function applyStatusTransition(task, status, now) {
91753
92611
  attentionAt: status === "input_required" ? now : null
91754
92612
  };
91755
92613
  }
92614
+ function arraysEqual(a, b2) {
92615
+ if (a.length !== b2.length) return false;
92616
+ for (let i = 0; i < a.length; i++) {
92617
+ if (a[i] !== b2[i]) return false;
92618
+ }
92619
+ return true;
92620
+ }
92621
+ var structuredTaskEqual = (x2, y) => {
92622
+ if (x2.id !== y.id) return false;
92623
+ if (x2.subject !== y.subject) return false;
92624
+ if (x2.description !== y.description) return false;
92625
+ if (x2.activeForm !== y.activeForm) return false;
92626
+ if (x2.owner !== y.owner) return false;
92627
+ if (x2.status !== y.status) return false;
92628
+ if (x2.createdAt !== y.createdAt) return false;
92629
+ if (x2.updatedAt !== y.updatedAt) return false;
92630
+ if (!arraysEqual(x2.blocks, y.blocks)) return false;
92631
+ if (!arraysEqual(x2.blockedBy, y.blockedBy)) return false;
92632
+ return true;
92633
+ };
92634
+ function structuredTasksEqual(a, b2) {
92635
+ const aKeys = Object.keys(a);
92636
+ if (aKeys.length !== Object.keys(b2).length) return false;
92637
+ for (const id of aKeys) {
92638
+ const x2 = a[id];
92639
+ const y = b2[id];
92640
+ if (!x2 || !y) return false;
92641
+ if (!structuredTaskEqual(x2, y)) return false;
92642
+ }
92643
+ return true;
92644
+ }
91756
92645
  function buildTaskStateStore(dataDir) {
91757
92646
  const store = buildJsonDocumentStore({
91758
92647
  filePath: join45(dataDir, "tasks.json"),
@@ -91834,6 +92723,11 @@ function buildTaskStateStore(dataDir) {
91834
92723
  cwd,
91835
92724
  totalCostUsd: 0,
91836
92725
  totalOutputTokens: 0,
92726
+ totalTurnCount: 0,
92727
+ roiStartedEmitted: false,
92728
+ mergedAt: null,
92729
+ attributedCommitShas: [],
92730
+ lastCommitScanSha: null,
91837
92731
  mode: mode ?? "task",
91838
92732
  ...scheduleId ? { scheduleId } : {},
91839
92733
  ...scheduleName ? { scheduleName } : {},
@@ -91895,20 +92789,25 @@ function buildTaskStateStore(dataDir) {
91895
92789
  );
91896
92790
  },
91897
92791
  async updateStructuredTasks(taskId, tasks, todoProgress, options) {
91898
- const now = Date.now();
91899
92792
  await safeUpdate(
91900
92793
  taskId,
91901
- (task) => ({
91902
- ...task,
91903
- structuredTasks: tasks,
91904
- ...todoProgress && {
91905
- todoCompleted: todoProgress.todoCompleted,
91906
- todoTotal: todoProgress.todoTotal,
91907
- currentActivity: todoProgress.currentActivity
91908
- },
91909
- updatedAt: now,
91910
- lastActivityAt: now
91911
- }),
92794
+ (task) => {
92795
+ const sameTasks = structuredTasksEqual(task.structuredTasks ?? {}, tasks);
92796
+ const sameProgress = !todoProgress || task.todoCompleted === todoProgress.todoCompleted && task.todoTotal === todoProgress.todoTotal && task.currentActivity === todoProgress.currentActivity;
92797
+ if (sameTasks && sameProgress) return task;
92798
+ const now = Date.now();
92799
+ return {
92800
+ ...task,
92801
+ structuredTasks: tasks,
92802
+ ...todoProgress && {
92803
+ todoCompleted: todoProgress.todoCompleted,
92804
+ todoTotal: todoProgress.todoTotal,
92805
+ currentActivity: todoProgress.currentActivity
92806
+ },
92807
+ updatedAt: now,
92808
+ lastActivityAt: now
92809
+ };
92810
+ },
91912
92811
  options
91913
92812
  );
91914
92813
  },
@@ -92004,7 +92903,48 @@ function buildTaskStateStore(dataDir) {
92004
92903
  await safeUpdate(
92005
92904
  taskId,
92006
92905
  (task) => task.abandonedAt != null ? task : { ...task, abandonedAt: timestamp },
92007
- { broadcast: false, ...options }
92906
+ { ...options, broadcast: false }
92907
+ );
92908
+ },
92909
+ async setRoiStartedEmitted(taskId, options) {
92910
+ await safeUpdate(
92911
+ taskId,
92912
+ (task) => task.roiStartedEmitted ? task : { ...task, roiStartedEmitted: true },
92913
+ { ...options, broadcast: false }
92914
+ );
92915
+ },
92916
+ async setMergedAt(taskId, mergedAt, options) {
92917
+ await safeUpdate(taskId, (task) => task.mergedAt != null ? task : { ...task, mergedAt }, {
92918
+ ...options,
92919
+ broadcast: false
92920
+ });
92921
+ },
92922
+ async setLastCommitScanSha(taskId, sha, options) {
92923
+ await safeUpdate(
92924
+ taskId,
92925
+ (task) => task.lastCommitScanSha === sha ? task : { ...task, lastCommitScanSha: sha },
92926
+ { ...options, broadcast: false }
92927
+ );
92928
+ },
92929
+ async addAttributedCommitSha(taskId, sha, options) {
92930
+ await safeUpdate(
92931
+ taskId,
92932
+ (task) => {
92933
+ if (task.attributedCommitShas.includes(sha)) return task;
92934
+ const next = [...task.attributedCommitShas, sha];
92935
+ return {
92936
+ ...task,
92937
+ attributedCommitShas: next.length > 50 ? next.slice(next.length - 50) : next
92938
+ };
92939
+ },
92940
+ { ...options, broadcast: false }
92941
+ );
92942
+ },
92943
+ async incrementTotalTurnCount(taskId, delta = 1, options) {
92944
+ await safeUpdate(
92945
+ taskId,
92946
+ (task) => ({ ...task, totalTurnCount: task.totalTurnCount + delta }),
92947
+ { ...options, broadcast: false }
92008
92948
  );
92009
92949
  },
92010
92950
  async sweepToInputRequired(taskId, options) {
@@ -94125,9 +95065,58 @@ async function createDaemon(deps) {
94125
95065
  const abandonedSweeper = createAbandonedSweeper({
94126
95066
  listTasks: () => taskStateStore.listTasks(),
94127
95067
  setAbandonedAt: (taskId, timestamp) => taskStateStore.setAbandonedAt(taskId, timestamp),
95068
+ onTaskAbandoned: (taskId, task, abandonedAt) => {
95069
+ if (task.taskStartedAt == null && (task.totalTurnCount ?? 0) === 0) return;
95070
+ emitTaskEnded(deps.metricsCollector, {
95071
+ taskId,
95072
+ userId: deps.auth.userId,
95073
+ finalStatus: "input_required",
95074
+ totalCostUsd: task.totalCostUsd ?? 0,
95075
+ totalOutputTokens: task.totalOutputTokens ?? 0,
95076
+ turnCount: task.totalTurnCount ?? 0,
95077
+ durationMs: Math.max(0, abandonedAt - task.createdAt),
95078
+ timestamp: abandonedAt
95079
+ });
95080
+ },
94128
95081
  log: (entry) => deps.log(entry)
94129
95082
  });
94130
95083
  abandonedSweeper.start();
95084
+ const commitSweepService = new CommitSweepService({
95085
+ metricsCollector: deps.metricsCollector,
95086
+ log: (entry) => deps.log(entry),
95087
+ setLastCommitScanSha: (taskId, sha) => taskStateStore.setLastCommitScanSha(taskId, sha, { broadcast: false }),
95088
+ addAttributedCommitSha: (taskId, sha) => taskStateStore.addAttributedCommitSha(taskId, sha, { broadcast: false }),
95089
+ injectTrailer: injectShipyardTrailer,
95090
+ /**
95091
+ * Periodic sweep never amends HEAD — a zero-cost trailer injected here
95092
+ * would permanently block future in-session scans from attributing the
95093
+ * same commit with real cost (hasShipyardTrailer skips already-trailered
95094
+ * commits). External commits are emitted as 'extended' instead (no
95095
+ * git-amend, attributed for bookkeeping only). See amendHead: false below.
95096
+ */
95097
+ amendHead: false,
95098
+ getAttributionPayload: (taskId) => ({
95099
+ model: "unknown",
95100
+ tokens: 0,
95101
+ costUsd: 0,
95102
+ turnCount: 0,
95103
+ sessionId: `periodic-sweep-${taskId}`
95104
+ }),
95105
+ getActiveTasks: () => taskManager.listManagedTasks().map(({ taskId, cwd }) => ({
95106
+ taskId,
95107
+ cwd: cwd ?? deps.workspaceRoot,
95108
+ userId: deps.auth.userId
95109
+ })),
95110
+ getTaskAttribution: async (taskId) => {
95111
+ const record = await taskStateStore.getTask(taskId);
95112
+ return {
95113
+ lastSeenSha: record?.lastCommitScanSha ?? null,
95114
+ attributedCommitShas: record?.attributedCommitShas ?? [],
95115
+ userId: deps.auth.userId
95116
+ };
95117
+ }
95118
+ });
95119
+ commitSweepService.start();
94131
95120
  const awarenessSampler = createLoroAwarenessSampler({
94132
95121
  getRepo: () => repo,
94133
95122
  listTaskIds: () => taskManager.listManagedTasks().map((t) => t.taskId),
@@ -94141,6 +95130,7 @@ async function createDaemon(deps) {
94141
95130
  async function dispose() {
94142
95131
  awarenessSampler.stop();
94143
95132
  abandonedSweeper.stop();
95133
+ commitSweepService.stop();
94144
95134
  scheduleEvaluator.dispose();
94145
95135
  preWarmManager.dispose();
94146
95136
  compactor?.dispose();
@@ -94531,6 +95521,7 @@ function filterOutboundForCollab(msg, collabTaskId) {
94531
95521
  /** Task-scoped: pass through only if taskId matches */
94532
95522
  case "task_state_update":
94533
95523
  case "task_removed":
95524
+ case "task_created_ack":
94534
95525
  case "permission_request":
94535
95526
  case "permission_resolved":
94536
95527
  case "pr_action_result":
@@ -94774,7 +95765,7 @@ function routeMessage(msg, callbacks, log) {
94774
95765
  );
94775
95766
  break;
94776
95767
  case "update_settings":
94777
- callbacks.onUpdateSettings(msg.settings);
95768
+ callbacks.onUpdateSettings(msg.settings, msg.correlationId);
94778
95769
  break;
94779
95770
  case "update_task_settings":
94780
95771
  callbacks.onUpdateTaskSettings(msg.taskId, msg.settings);
@@ -96356,6 +97347,31 @@ function sendPluginAuthStatusesIfTokens(handler, tokenStore, logAdapter) {
96356
97347
  });
96357
97348
  });
96358
97349
  }
97350
+ async function emitTaskCreatedAck(controlHandler, daemon, taskId, templateId, logAdapter) {
97351
+ try {
97352
+ const template = templateId ? await daemon.templateStore.get(templateId) ?? null : null;
97353
+ controlHandler.sendControl({
97354
+ type: "task_created_ack",
97355
+ taskId,
97356
+ templateId: templateId ?? null,
97357
+ appliedTemplate: template ? {
97358
+ templateId: template.id,
97359
+ todos: template.items.map((item2) => ({
97360
+ id: item2.id,
97361
+ content: item2.content,
97362
+ deps: item2.deps
97363
+ }))
97364
+ } : null
97365
+ });
97366
+ } catch (err) {
97367
+ logAdapter({
97368
+ event: "task_created_ack_failed",
97369
+ taskId,
97370
+ templateId: templateId ?? null,
97371
+ error: err instanceof Error ? err.message : String(err)
97372
+ });
97373
+ }
97374
+ }
96359
97375
  function wireControlChannel(rawChannel, daemon, logAdapter, deps) {
96360
97376
  const dc = narrow(rawChannel);
96361
97377
  const wsRoot = deps?.workspaceRoot ?? findProjectRoot(process.cwd());
@@ -96430,11 +97446,128 @@ function wireControlChannel(rawChannel, daemon, logAdapter, deps) {
96430
97446
  logAdapter
96431
97447
  });
96432
97448
  const prAttributedTaskIds = /* @__PURE__ */ new Set();
97449
+ const prMergedTaskIds = /* @__PURE__ */ new Set();
97450
+ const seedReadyPromise = daemon.taskStateStore.listTasks().then((tasks) => {
97451
+ for (const [taskId, record] of Object.entries(tasks)) {
97452
+ if (record.prUrl != null) prAttributedTaskIds.add(taskId);
97453
+ if (record.mergedAt != null) prMergedTaskIds.add(taskId);
97454
+ }
97455
+ }).catch((err) => {
97456
+ logAdapter({
97457
+ event: "pr_sets_seed_failed",
97458
+ error: err instanceof Error ? err.message : String(err)
97459
+ });
97460
+ });
97461
+ const defaultBranchCache2 = /* @__PURE__ */ new Map();
97462
+ async function resolveDefaultBranch2(cwd) {
97463
+ const cached2 = defaultBranchCache2.get(cwd);
97464
+ if (cached2) return cached2;
97465
+ let resolved;
97466
+ try {
97467
+ const branch = await run(
97468
+ "gh",
97469
+ ["repo", "view", "--json", "defaultBranchRef", "-q", ".defaultBranchRef.name"],
97470
+ cwd
97471
+ );
97472
+ const trimmed = branch.trim();
97473
+ if (!trimmed) {
97474
+ return "main";
97475
+ }
97476
+ resolved = trimmed;
97477
+ } catch {
97478
+ return "main";
97479
+ }
97480
+ defaultBranchCache2.set(cwd, resolved);
97481
+ return resolved;
97482
+ }
97483
+ async function findCommitByTrailer(cwd, trunk, taskId) {
97484
+ const sha = await run(
97485
+ "git",
97486
+ ["log", `origin/${trunk}`, `--grep=Shipyard-Task-Id: ${taskId}`, "-n1", "--pretty=%H"],
97487
+ cwd
97488
+ ).catch(() => "");
97489
+ return sha.trim() || null;
97490
+ }
97491
+ async function detectMergeIfNew(pr, taskId, cwd) {
97492
+ if (pr.state === "merged" || pr.mergeCommitSha != null) {
97493
+ return {
97494
+ merged: true,
97495
+ method: "github_native",
97496
+ sha: pr.mergeCommitSha,
97497
+ at: pr.mergedAt ?? Date.now()
97498
+ };
97499
+ }
97500
+ if (pr.state === "closed" && pr.headCommitInBase) {
97501
+ return {
97502
+ merged: true,
97503
+ method: "graphite_queue",
97504
+ sha: pr.headRefSha,
97505
+ at: Date.now()
97506
+ };
97507
+ }
97508
+ if (pr.state === "closed") {
97509
+ const trunk = await resolveDefaultBranch2(cwd);
97510
+ await run("git", ["fetch", "origin", trunk, "--quiet"], cwd, 1e4).catch((err) => {
97511
+ logAdapter({
97512
+ event: "roi_tier3_fetch_failed",
97513
+ taskId,
97514
+ trunk,
97515
+ error: err instanceof Error ? err.message : String(err)
97516
+ });
97517
+ });
97518
+ const sha = await findCommitByTrailer(cwd, trunk, taskId);
97519
+ if (sha) {
97520
+ return { merged: true, method: "graphite_queue", sha, at: Date.now() };
97521
+ }
97522
+ }
97523
+ return { merged: false };
97524
+ }
96433
97525
  const prPoller = createPRPoller(
96434
97526
  {
96435
97527
  onPRState: (payload) => {
96436
97528
  controlHandler.sendControl({ type: "pr_state", data: payload });
96437
- attributePrIfFirstDiscovery(payload);
97529
+ seedReadyPromise.then(() => {
97530
+ attributePrIfFirstDiscovery(payload);
97531
+ const pr = payload.currentBranchPR;
97532
+ if (!pr || prMergedTaskIds.has(payload.taskId)) return;
97533
+ const { taskId } = payload;
97534
+ const cwd = prPoller.getCwd(taskId);
97535
+ if (!cwd) return;
97536
+ prMergedTaskIds.add(taskId);
97537
+ detectMergeIfNew(pr, taskId, cwd).then((result) => {
97538
+ if (!result.merged) {
97539
+ prMergedTaskIds.delete(taskId);
97540
+ return;
97541
+ }
97542
+ daemon.taskStateStore.setMergedAt(taskId, result.at).catch((err) => {
97543
+ logAdapter({
97544
+ event: "roi_pr_merged_store_failed",
97545
+ taskId,
97546
+ error: err instanceof Error ? err.message : String(err)
97547
+ });
97548
+ });
97549
+ const remote = parseOwnerRepo(pr.url);
97550
+ const repoKey = remote ? `${remote.owner}/${remote.repo}` : pr.url;
97551
+ emitPrMerged(daemon.metricsCollector, {
97552
+ taskId,
97553
+ userId: daemon.userId,
97554
+ prUrl: pr.url,
97555
+ prNumber: pr.number,
97556
+ repo: repoKey,
97557
+ mergeCommitSha: result.sha,
97558
+ mergeMethod: result.method,
97559
+ mergedAt: result.at
97560
+ });
97561
+ }).catch((err) => {
97562
+ prMergedTaskIds.delete(taskId);
97563
+ logAdapter({
97564
+ event: "roi_pr_merged_detection_failed",
97565
+ taskId,
97566
+ error: err instanceof Error ? err.message : String(err)
97567
+ });
97568
+ });
97569
+ }).catch(() => {
97570
+ });
96438
97571
  }
96439
97572
  },
96440
97573
  prPollerLog
@@ -96521,7 +97654,7 @@ function wireControlChannel(rawChannel, daemon, logAdapter, deps) {
96521
97654
  senderParticipantId
96522
97655
  );
96523
97656
  },
96524
- onUpdateSettings: (settings) => {
97657
+ onUpdateSettings: (settings, correlationId) => {
96525
97658
  if (settings.disabledMcpServers && daemon.capabilities.mcpServers) {
96526
97659
  const prevDisabled = new Set(
96527
97660
  daemon.capabilities.mcpServers.filter((s2) => !s2.enabled).map((s2) => s2.name)
@@ -96547,7 +97680,11 @@ function wireControlChannel(rawChannel, daemon, logAdapter, deps) {
96547
97680
  });
96548
97681
  });
96549
97682
  }
96550
- handler.sendControl({ type: "settings_ack", settings });
97683
+ handler.sendControl({
97684
+ type: "settings_ack",
97685
+ settings,
97686
+ ...correlationId !== void 0 && { correlationId }
97687
+ });
96551
97688
  logAdapter({ event: "settings_updated", settings });
96552
97689
  },
96553
97690
  onUpdateTaskSettings: (taskId, settings) => {
@@ -96982,6 +98119,7 @@ function wireControlChannel(rawChannel, daemon, logAdapter, deps) {
96982
98119
  },
96983
98120
  onCreateTask: (taskId, channelId, title, cwd, mode, templateId) => {
96984
98121
  daemon.taskManager.createTask({ taskId, channelId, title, cwd, mode, templateId });
98122
+ void emitTaskCreatedAck(controlHandler, daemon, taskId, templateId, logAdapter);
96985
98123
  },
96986
98124
  onPromoteTask: (taskId) => {
96987
98125
  daemon.taskManager.promoteTask(taskId);
@@ -97737,7 +98875,7 @@ function createCollabRoomManager(deps) {
97737
98875
  getParticipantsForTask(taskId) {
97738
98876
  for (const room of rooms.values()) {
97739
98877
  if (room.taskId === taskId) {
97740
- return room.participants.map((p2) => ({ name: p2.username, role: p2.role }));
98878
+ return room.participants.filter((p2) => p2.userId !== room.myUserId).map((p2) => ({ name: p2.username, role: p2.role }));
97741
98879
  }
97742
98880
  }
97743
98881
  return [];
@@ -97998,13 +99136,21 @@ function handleFileIOChannel(initialCwd, send, log, deps) {
97998
99136
  respond({ type: "error", requestId, error: error2 });
97999
99137
  }
98000
99138
  function safePath(userPath) {
99139
+ return safePathWithOverrides(userPath, null);
99140
+ }
99141
+ function safePathForRead(userPath) {
99142
+ return safePathWithOverrides(userPath, /* @__PURE__ */ new Set(["node_modules"]));
99143
+ }
99144
+ function safePathWithOverrides(userPath, allowedHiddenNames) {
98001
99145
  const normalized = normalize6(userPath);
98002
99146
  if (normalized.startsWith("..") || normalized.includes("/..") || normalized.includes("\\..")) {
98003
99147
  return null;
98004
99148
  }
98005
99149
  const segments = normalized.split(/[/\\]/).filter((s2) => s2.length > 0 && s2 !== ".");
98006
99150
  for (const seg of segments) {
98007
- if (isHidden(seg)) return null;
99151
+ if (!isHidden(seg)) continue;
99152
+ if (allowedHiddenNames?.has(seg)) continue;
99153
+ return null;
98008
99154
  }
98009
99155
  const abs = resolve2(cwd, normalized);
98010
99156
  const rel = relative3(cwd, abs);
@@ -98031,7 +99177,8 @@ function handleFileIOChannel(initialCwd, send, log, deps) {
98031
99177
  function validatePath(msg) {
98032
99178
  const abs = safePath(msg.path);
98033
99179
  if (!abs) {
98034
- respondError(msg.requestId, "Invalid path");
99180
+ log({ event: "file_io_path_rejected", path: msg.path });
99181
+ respondError(msg.requestId, `hidden_path:${msg.path}`);
98035
99182
  return null;
98036
99183
  }
98037
99184
  return { abs, rel: relative3(cwd, abs) };
@@ -98063,10 +99210,11 @@ function handleFileIOChannel(initialCwd, send, log, deps) {
98063
99210
  }
98064
99211
  }
98065
99212
  function dispatchFileOp(msg) {
98066
- const abs = safePath(msg.path);
99213
+ const isReadOnlyOp = msg.type === "read_file" || msg.type === "stat";
99214
+ const abs = isReadOnlyOp ? safePathForRead(msg.path) : safePath(msg.path);
98067
99215
  if (!abs) {
98068
99216
  log({ event: "file_io_path_rejected", path: msg.path });
98069
- respondError(msg.requestId, "Invalid path");
99217
+ respondError(msg.requestId, `hidden_path:${msg.path}`);
98070
99218
  return;
98071
99219
  }
98072
99220
  switch (msg.type) {
@@ -98232,7 +99380,7 @@ function handleFileIOChannel(initialCwd, send, log, deps) {
98232
99380
  }
98233
99381
  cwd = canonical;
98234
99382
  log({ event: "file_io_cwd_changed", cwd: canonical });
98235
- respond({ type: "set_cwd_ack", requestId });
99383
+ respond({ type: "set_cwd_ack", requestId, canonical });
98236
99384
  for (const listener of cwdChangeListeners) {
98237
99385
  listener(canonical);
98238
99386
  }
@@ -98282,13 +99430,19 @@ function handleFileIOChannel(initialCwd, send, log, deps) {
98282
99430
  try {
98283
99431
  const { stdout } = await execFileAsync3("git", args, {
98284
99432
  cwd,
98285
- maxBuffer: 10 * 1024 * 1024
99433
+ maxBuffer: 10 * 1024 * 1024,
99434
+ timeout: 1e4,
99435
+ killSignal: "SIGKILL"
98286
99436
  });
98287
99437
  const parsed = parseGitGrepOutput(stdout, maxResults);
99438
+ const filteredMatches = parsed.matches.filter((m2) => {
99439
+ const segments = m2.path.split(/[/\\]/).filter((s2) => s2.length > 0 && s2 !== ".");
99440
+ return segments.every((seg) => !isHidden(seg) || seg === "node_modules");
99441
+ });
98288
99442
  respond({
98289
99443
  type: "git_grep_result",
98290
99444
  requestId,
98291
- matches: parsed.matches,
99445
+ matches: filteredMatches,
98292
99446
  truncated: parsed.truncated
98293
99447
  });
98294
99448
  } catch (err) {
@@ -98838,22 +99992,34 @@ function handleMessageChannel(opts) {
98838
99992
  peerRole,
98839
99993
  senderDisplayName
98840
99994
  } = opts;
98841
- let catchUpInProgress = false;
98842
- const bufferedLiveMessages = [];
98843
- const unsubscribe = store.subscribe(channelId, (message) => {
98844
- const outgoing = { type: "message", message };
98845
- if (catchUpInProgress) {
98846
- bufferedLiveMessages.push(outgoing);
98847
- return;
98848
- }
98849
- send(JSON.stringify(outgoing));
98850
- });
99995
+ const inFlightCorrelations = /* @__PURE__ */ new Set();
98851
99996
  function sendError(error2) {
98852
99997
  send(JSON.stringify({ type: "error", error: error2 }));
98853
99998
  }
98854
99999
  function formatError2(err) {
98855
100000
  return err instanceof Error ? err.message : String(err);
98856
100001
  }
100002
+ function sendAck(correlationId, stage, error2) {
100003
+ const ack = error2 ? { type: "send_message_ack", correlationId, stage, error: error2 } : { type: "send_message_ack", correlationId, stage };
100004
+ send(JSON.stringify(ack));
100005
+ }
100006
+ let unregisterConfirmedEmitter = null;
100007
+ if (opts.registerConfirmedAckEmitter) {
100008
+ unregisterConfirmedEmitter = opts.registerConfirmedAckEmitter((correlationId) => {
100009
+ if (inFlightCorrelations.has(correlationId)) {
100010
+ inFlightCorrelations.delete(correlationId);
100011
+ sendAck(correlationId, "confirmed");
100012
+ }
100013
+ });
100014
+ }
100015
+ let unregisterForwardedEmitter = null;
100016
+ if (opts.registerForwardedAckEmitter) {
100017
+ unregisterForwardedEmitter = opts.registerForwardedAckEmitter((correlationId) => {
100018
+ if (inFlightCorrelations.has(correlationId)) {
100019
+ sendAck(correlationId, "forwarded");
100020
+ }
100021
+ });
100022
+ }
98857
100023
  function parseAndValidate(data) {
98858
100024
  let raw;
98859
100025
  try {
@@ -98873,14 +100039,51 @@ function handleMessageChannel(opts) {
98873
100039
  }
98874
100040
  return result.data;
98875
100041
  }
100042
+ let catchUpInProgress = false;
100043
+ const bufferedLiveMessages = [];
100044
+ const unsubscribe = store.subscribe(channelId, (message) => {
100045
+ const outgoing = { type: "message", message };
100046
+ if (catchUpInProgress) {
100047
+ bufferedLiveMessages.push(outgoing);
100048
+ return;
100049
+ }
100050
+ send(JSON.stringify(outgoing));
100051
+ });
98876
100052
  async function handleSendMessage2(msg) {
100053
+ const { correlationId } = msg;
98877
100054
  try {
100055
+ sendAck(correlationId, "accepted");
98878
100056
  const settings = {
98879
100057
  model: msg.model,
98880
100058
  reasoningEffort: msg.reasoningEffort,
98881
100059
  permissionMode: msg.permissionMode,
98882
100060
  fastMode: msg.fastMode
98883
100061
  };
100062
+ const appendResult = await store.appendMessageDeduped(
100063
+ {
100064
+ messageId: crypto.randomUUID(),
100065
+ channelId,
100066
+ participantId: participantId ?? "human:unknown",
100067
+ senderKind: "human",
100068
+ content: msg.content,
100069
+ timestamp: Date.now(),
100070
+ correlationId,
100071
+ model: settings.model ?? null,
100072
+ reasoningEffort: settings.reasoningEffort ?? null,
100073
+ permissionMode: settings.permissionMode ?? null
100074
+ },
100075
+ {}
100076
+ );
100077
+ if (appendResult.isDuplicate) {
100078
+ log({
100079
+ event: "jsonl_dedup_correlation",
100080
+ taskId,
100081
+ correlationId,
100082
+ dedupKey: appendResult.dedupKey
100083
+ });
100084
+ }
100085
+ sendAck(correlationId, "persisted");
100086
+ inFlightCorrelations.add(correlationId);
98884
100087
  if (opts.onUserMessage) {
98885
100088
  opts.onUserMessage(msg.content, settings, msg.cwd, participantId, senderDisplayName);
98886
100089
  } else {
@@ -98890,12 +100093,14 @@ function handleMessageChannel(opts) {
98890
100093
  settings,
98891
100094
  msg.cwd,
98892
100095
  participantId,
98893
- senderDisplayName
100096
+ senderDisplayName,
100097
+ correlationId
98894
100098
  );
98895
100099
  }
98896
100100
  } catch (err) {
98897
- sendError(formatError2(err));
98898
- log({ event: "message_handler_error", taskId, error: formatError2(err) });
100101
+ sendAck(correlationId, "rejected", formatError2(err));
100102
+ inFlightCorrelations.delete(correlationId);
100103
+ log({ event: "message_handler_error", taskId, correlationId, error: formatError2(err) });
98899
100104
  }
98900
100105
  }
98901
100106
  function flushBufferedMessages() {
@@ -98905,8 +100110,48 @@ function handleMessageChannel(opts) {
98905
100110
  }
98906
100111
  bufferedLiveMessages.length = 0;
98907
100112
  }
100113
+ function classifyCorrelationStatus(cid, persisted) {
100114
+ const stored = persisted.get(cid);
100115
+ if (stored === "confirmed") return "confirmed";
100116
+ if (stored === "persisted") return inFlightCorrelations.has(cid) ? "forwarded" : "persisted";
100117
+ return "unknown";
100118
+ }
100119
+ function buildPersistedMap(messages) {
100120
+ const persisted = /* @__PURE__ */ new Map();
100121
+ for (const m2 of messages) {
100122
+ if (m2.correlationId) {
100123
+ const status = m2.sdkUuid ? "confirmed" : "persisted";
100124
+ persisted.set(m2.correlationId, status);
100125
+ for (const secondary of m2.batchCorrelationIds ?? []) {
100126
+ persisted.set(secondary, status);
100127
+ }
100128
+ }
100129
+ }
100130
+ return persisted;
100131
+ }
100132
+ function sendCorrelationSnapshot(inFlightIds, persisted) {
100133
+ const entries = inFlightIds.map((cid) => ({
100134
+ correlationId: cid,
100135
+ status: classifyCorrelationStatus(cid, persisted)
100136
+ }));
100137
+ const snapshot = { type: "correlation_status_snapshot", entries };
100138
+ send(JSON.stringify(snapshot));
100139
+ }
98908
100140
  function handleSubscribe(msg) {
98909
100141
  catchUpInProgress = true;
100142
+ if (msg.inFlightCorrelationIds && msg.inFlightCorrelationIds.length > 0) {
100143
+ const inFlightIds = msg.inFlightCorrelationIds;
100144
+ store.getMessages(channelId).then((messages) => {
100145
+ const persisted = buildPersistedMap(messages);
100146
+ for (const cid of inFlightIds) {
100147
+ if (persisted.get(cid) === "persisted") {
100148
+ inFlightCorrelations.add(cid);
100149
+ }
100150
+ }
100151
+ sendCorrelationSnapshot(inFlightIds, persisted);
100152
+ }).catch(() => {
100153
+ });
100154
+ }
98910
100155
  if (msg.maxMessages && msg.sinceSeqNo === 0) {
98911
100156
  const limit = msg.maxMessages;
98912
100157
  store.getMessagesBefore(channelId, Number.POSITIVE_INFINITY, limit).then(({ messages: page }) => {
@@ -99040,6 +100285,11 @@ function handleMessageChannel(opts) {
99040
100285
  sendStreamDelta,
99041
100286
  dispose() {
99042
100287
  unsubscribe();
100288
+ unregisterConfirmedEmitter?.();
100289
+ unregisterConfirmedEmitter = null;
100290
+ unregisterForwardedEmitter?.();
100291
+ unregisterForwardedEmitter = null;
100292
+ inFlightCorrelations.clear();
99043
100293
  }
99044
100294
  };
99045
100295
  }
@@ -99566,6 +100816,10 @@ function attachConversationHandler(daemon, dc, channelId, params, log, attempts)
99566
100816
  taskManager: daemon.taskManager,
99567
100817
  store: daemon.store,
99568
100818
  log,
100819
+ ...!threadId && {
100820
+ registerConfirmedAckEmitter: (emitter) => daemon.taskManager.registerConfirmedAckEmitter(taskId, emitter),
100821
+ registerForwardedAckEmitter: (emitter) => daemon.taskManager.registerForwardedAckEmitter(taskId, emitter)
100822
+ },
99569
100823
  ...peer && {
99570
100824
  participantId: peer.participantId,
99571
100825
  peerRole: peer.peerRole,
@@ -101706,7 +102960,9 @@ async function serve(options = {}) {
101706
102960
  const shipyardHome = options.shipyardHome ?? getShipyardHome();
101707
102961
  const dataDir = join55(shipyardHome, options.isDev ? "data-dev" : "data");
101708
102962
  const log = createChildLogger({ mode: "serve" });
101709
- const workspaceRoot = findProjectRoot(process.cwd());
102963
+ const workspaceRoot = await realpath2(findProjectRoot(process.cwd())).catch(
102964
+ () => findProjectRoot(process.cwd())
102965
+ );
101710
102966
  registerBuiltinPlugins();
101711
102967
  const pluginConfigStore = buildPluginConfigStore(join55(dataDir, "plugins"));
101712
102968
  await mkdir24(dataDir, { recursive: true });
@@ -102048,4 +103304,4 @@ export {
102048
103304
  _testing,
102049
103305
  serve
102050
103306
  };
102051
- //# sourceMappingURL=serve-3EFFP3PN.js.map
103307
+ //# sourceMappingURL=serve-P5WC5JIT.js.map