@stigmer/runner 3.1.3 → 3.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.js +1 -1
  3. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  4. package/dist/activities/execute-cursor/capture-flow.d.ts +26 -15
  5. package/dist/activities/execute-cursor/capture-flow.js +56 -18
  6. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  7. package/dist/activities/execute-cursor/command-provenance.d.ts +11 -25
  8. package/dist/activities/execute-cursor/command-provenance.js +25 -115
  9. package/dist/activities/execute-cursor/command-provenance.js.map +1 -1
  10. package/dist/activities/execute-cursor/index.js +297 -478
  11. package/dist/activities/execute-cursor/index.js.map +1 -1
  12. package/dist/activities/execute-cursor/skill-resolver.js +1 -1
  13. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  14. package/dist/activities/execute-cursor/todo-tracker.d.ts +6 -1
  15. package/dist/activities/execute-cursor/todo-tracker.js +15 -43
  16. package/dist/activities/execute-cursor/todo-tracker.js.map +1 -1
  17. package/dist/activities/execute-cursor/turn-stream.d.ts +141 -0
  18. package/dist/activities/execute-cursor/turn-stream.js +249 -0
  19. package/dist/activities/execute-cursor/turn-stream.js.map +1 -0
  20. package/dist/activities/execute-deep-agent/command-provenance.d.ts +61 -0
  21. package/dist/activities/execute-deep-agent/command-provenance.js +72 -0
  22. package/dist/activities/execute-deep-agent/command-provenance.js.map +1 -0
  23. package/dist/activities/execute-deep-agent/index.js +88 -20
  24. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  25. package/dist/activities/execute-deep-agent/status-builder.js +8 -1
  26. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  27. package/dist/activities/execute-deep-agent/v3-status-builder.js +12 -1
  28. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  29. package/dist/activities/execute-deep-agent/writeback-coordinator.js +5 -1
  30. package/dist/activities/execute-deep-agent/writeback-coordinator.js.map +1 -1
  31. package/dist/otel.js +10 -0
  32. package/dist/otel.js.map +1 -1
  33. package/dist/shared/filereview/cas-progress.d.ts +63 -0
  34. package/dist/shared/filereview/cas-progress.js +128 -0
  35. package/dist/shared/filereview/cas-progress.js.map +1 -0
  36. package/dist/shared/filereview/cas-substrate.d.ts +29 -0
  37. package/dist/shared/filereview/cas-substrate.js +46 -21
  38. package/dist/shared/filereview/cas-substrate.js.map +1 -1
  39. package/dist/shared/filereview/command-provenance.d.ts +93 -0
  40. package/dist/shared/filereview/command-provenance.js +132 -0
  41. package/dist/shared/filereview/command-provenance.js.map +1 -0
  42. package/dist/shared/filereview/git-substrate.d.ts +9 -3
  43. package/dist/shared/filereview/git-substrate.js +4 -0
  44. package/dist/shared/filereview/git-substrate.js.map +1 -1
  45. package/dist/shared/filereview/index.d.ts +4 -3
  46. package/dist/shared/filereview/index.js +3 -2
  47. package/dist/shared/filereview/index.js.map +1 -1
  48. package/dist/shared/filereview/progress.d.ts +105 -34
  49. package/dist/shared/filereview/progress.js +96 -34
  50. package/dist/shared/filereview/progress.js.map +1 -1
  51. package/dist/shared/plan-mode-prompt.d.ts +9 -0
  52. package/dist/shared/plan-mode-prompt.js +18 -0
  53. package/dist/shared/plan-mode-prompt.js.map +1 -1
  54. package/dist/shared/todos.d.ts +56 -0
  55. package/dist/shared/todos.js +98 -0
  56. package/dist/shared/todos.js.map +1 -0
  57. package/dist/shared/tool-row.d.ts +16 -0
  58. package/dist/shared/tool-row.js +31 -0
  59. package/dist/shared/tool-row.js.map +1 -1
  60. package/dist/shared/workspace/git-identity.d.ts +36 -0
  61. package/dist/shared/workspace/git-identity.js +39 -0
  62. package/dist/shared/workspace/git-identity.js.map +1 -0
  63. package/dist/shared/workspace/local-backend.d.ts +6 -2
  64. package/dist/shared/workspace/local-backend.js +6 -2
  65. package/dist/shared/workspace/local-backend.js.map +1 -1
  66. package/dist/shared/workspace/stigmer-link.d.ts +54 -0
  67. package/dist/shared/workspace/stigmer-link.js +92 -0
  68. package/dist/shared/workspace/stigmer-link.js.map +1 -0
  69. package/dist/shared/workspace/types.d.ts +6 -4
  70. package/package.json +2 -2
  71. package/src/__tests__/otel-turn-span.test.ts +61 -0
  72. package/src/activities/execute-cursor/__tests__/progress-substrate.test.ts +169 -0
  73. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +349 -0
  74. package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
  75. package/src/activities/execute-cursor/capture-flow.ts +71 -25
  76. package/src/activities/execute-cursor/command-provenance.ts +25 -120
  77. package/src/activities/execute-cursor/index.ts +345 -505
  78. package/src/activities/execute-cursor/skill-resolver.ts +1 -1
  79. package/src/activities/execute-cursor/todo-tracker.ts +17 -59
  80. package/src/activities/execute-cursor/turn-stream.ts +418 -0
  81. package/src/activities/execute-deep-agent/__tests__/command-provenance.test.ts +252 -0
  82. package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +78 -0
  83. package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +105 -1
  84. package/src/activities/execute-deep-agent/__tests__/writeback-coordinator.test.ts +30 -3
  85. package/src/activities/execute-deep-agent/command-provenance.ts +102 -0
  86. package/src/activities/execute-deep-agent/index.ts +107 -20
  87. package/src/activities/execute-deep-agent/status-builder.ts +9 -0
  88. package/src/activities/execute-deep-agent/v3-status-builder.ts +13 -0
  89. package/src/activities/execute-deep-agent/writeback-coordinator.ts +5 -1
  90. package/src/otel.ts +8 -0
  91. package/src/shared/__tests__/todos.test.ts +216 -0
  92. package/src/shared/filereview/__tests__/cas-progress.test.ts +228 -0
  93. package/src/shared/filereview/__tests__/cas-substrate.test.ts +66 -0
  94. package/src/shared/filereview/__tests__/command-provenance.test.ts +252 -0
  95. package/src/shared/filereview/__tests__/progress.test.ts +112 -10
  96. package/src/shared/filereview/cas-progress.ts +170 -0
  97. package/src/shared/filereview/cas-substrate.ts +69 -24
  98. package/src/shared/filereview/command-provenance.ts +180 -0
  99. package/src/shared/filereview/git-substrate.ts +13 -3
  100. package/src/shared/filereview/index.ts +15 -1
  101. package/src/shared/filereview/progress.ts +171 -47
  102. package/src/shared/plan-mode-prompt.ts +18 -0
  103. package/src/shared/todos.ts +126 -0
  104. package/src/shared/tool-row.ts +34 -0
  105. package/src/shared/workspace/__tests__/git-identity.test.ts +124 -0
  106. package/src/shared/workspace/__tests__/stigmer-link.test.ts +173 -0
  107. package/src/shared/workspace/git-identity.ts +41 -0
  108. package/src/shared/workspace/local-backend.ts +6 -2
  109. package/src/shared/workspace/stigmer-link.ts +97 -0
  110. package/src/shared/workspace/types.ts +6 -4
  111. package/dist/activities/execute-cursor/stigmer-link.d.ts +0 -35
  112. package/dist/activities/execute-cursor/stigmer-link.js +0 -73
  113. package/dist/activities/execute-cursor/stigmer-link.js.map +0 -1
  114. package/src/activities/execute-cursor/stigmer-link.ts +0 -78
@@ -26,6 +26,10 @@ import {
26
26
  WorkspaceLockTimeoutError,
27
27
  type ReleaseWorkspaceLock,
28
28
  } from "../../shared/workspace/workspace-lock.js";
29
+ import {
30
+ ensureStigmerSymlink,
31
+ removeStigmerSymlink,
32
+ } from "../../shared/workspace/stigmer-link.js";
29
33
  import type { ToolOutputOffloadContext } from "../../shared/status-offload.js";
30
34
  import { publishPlanArtifact } from "../../shared/plan-artifact.js";
31
35
  import { classifyTool } from "../../shared/tool-kind.js";
@@ -52,14 +56,26 @@ import {
52
56
  } from "../../shared/filereview/capture.js";
53
57
  import {
54
58
  captureFileChangeProgress,
59
+ createGitProgressSubstrate,
60
+ createHybridProgressSubstrate,
55
61
  newProgressCaptureState,
62
+ type ProgressSubstrate,
56
63
  } from "../../shared/filereview/progress.js";
64
+ import {
65
+ createCasProgressSubstrate,
66
+ type CasTouchedSnapshot,
67
+ } from "../../shared/filereview/cas-progress.js";
57
68
  import { hasCandidateCaptured } from "../../shared/filereview/events.js";
58
69
  import { casBlobReader, type CasPathCapture } from "../../shared/filereview/cas-substrate.js";
59
70
  import { partitionIgnoredPathsBySecret } from "../../shared/filereview/secret-paths.js";
60
71
  import type { CasCaptureObserver } from "./cas-capture-observer.js";
61
- import { collectSubAgentToolCallIds, withholdSecretContentFromMessages } from "../../shared/tool-row.js";
72
+ import {
73
+ collectSettledToolCallIds,
74
+ collectSubAgentToolCallIds,
75
+ withholdSecretContentFromMessages,
76
+ } from "../../shared/tool-row.js";
62
77
  import { stampFlowedFileEditRows, stampFlowedSubAgentFileEditRows } from "./stamp-flowed-rows.js";
78
+ import { deriveTurnCommandProvenance } from "./command-provenance.js";
63
79
 
64
80
  /** The harness id stamped on the deep-agent's file-review ledger events. */
65
81
  const DEEP_AGENT_HARNESS_ID = "deep-agent";
@@ -217,6 +233,22 @@ export function createDeepAgentActivities(config: Config) {
217
233
  throw lockErr;
218
234
  }
219
235
 
236
+ // Bridge the workspace to the session's platform dir so the agent's
237
+ // file tools can read platform-mounted content (`.stigmer/inputs/…`
238
+ // attachments incl. the approved plan, `.stigmer/skills/…`) — the
239
+ // deepagents FilesystemBackend resolves paths against the workspace
240
+ // root and knows nothing of the LocalWorkspaceBackend's `.stigmer`
241
+ // routing. Two ordering invariants (see shared/workspace/stigmer-link.ts):
242
+ // - AFTER the lock: the link is a tree mutation; sessions sharing one
243
+ // attached directory must not re-point it under a running turn.
244
+ // - BEFORE the baseline capture below: present in both the baseline
245
+ // and candidate trees, the link cancels out of the file-review diff
246
+ // even when the workspace's git excludes do not list `.stigmer`.
247
+ // Removed in cleanup() (the finally below), before the lock releases.
248
+ if (setup.workspaceBackend.platformDir) {
249
+ await ensureStigmerSymlink(gitRoot, setup.workspaceBackend.platformDir);
250
+ }
251
+
220
252
  // (1) Capture-mode resume — reconcile any DECIDED change set FIRST (this
221
253
  // drops the per-execution refs), before the next baseline re-pins them
222
254
  // (refs are executionId-keyed). Then (2) short-circuit a pure file-review
@@ -303,8 +335,10 @@ export function createDeepAgentActivities(config: Config) {
303
335
  }
304
336
 
305
337
  // Turn start (capture mode): pin the pre-turn tree + author BASELINE. Taken
306
- // AFTER any reconcile above and AFTER performSetup's workspace writes, so
307
- // the baseline absorbs the reconciled state and the runner-owned files.
338
+ // AFTER any reconcile above and AFTER performSetup's workspace writes and
339
+ // the `.stigmer` symlink above, so the baseline absorbs the reconciled
340
+ // state and the runner-owned files (the symlink then cancels out of the
341
+ // baseline→candidate diff).
308
342
  let captureBaselineTree = "";
309
343
  // Snapshot the sub-agent tool-call ids that exist BEFORE this turn's
310
344
  // stream, so the turn-boundary stamp scopes itself to sub-agent rows
@@ -312,6 +346,12 @@ export function createDeepAgentActivities(config: Config) {
312
346
  // seeds prior sub-agents — but computed, not assumed, so it stays correct
313
347
  // if that ever changes; see collectSubAgentToolCallIds).
314
348
  const priorSubAgentToolCallIds = collectSubAgentToolCallIds(initialStatus.subAgentExecutions);
349
+ // Snapshot the top-level tool-call ids already SETTLED before this turn's
350
+ // stream, so the approved-command provenance (DD-28) scopes itself to THIS
351
+ // turn's executed commands by identity. The deep-agent's approved shell
352
+ // executes in place at its seeded position, so the Cursor positional scope
353
+ // would miss it — see execute-deep-agent/command-provenance.ts.
354
+ const priorSettledToolCallIds = collectSettledToolCallIds(initialStatus.messages);
315
355
  if (setup.captureMode) {
316
356
  captureBaselineTree = await captureBaselineToLedger({
317
357
  status: initialStatus,
@@ -323,14 +363,34 @@ export function createDeepAgentActivities(config: Config) {
323
363
  });
324
364
  }
325
365
 
326
- // Per-turn state for mid-run live capture (DD-32): the last progress tree
327
- // sha (short-circuit) + last capture time (floor), threaded across the
328
- // streaming loop's persists via the beforePersist hook below. The capture
329
- // flags are hoisted so the deferred hook does not depend on `setup`
330
- // (non-null here, but not narrowable inside a later-invoked closure).
366
+ // Per-turn state + substrate for mid-run live capture (DD-32 / DD-33). The
367
+ // floor lives in progressState; the substrate is chosen for this turn's
368
+ // workspace shape and owns its own short-circuit cache. An atomic snapshot
369
+ // of the shared observer feeds the CAS/HYBRID substrates copied
370
+ // synchronously so a concurrent sub-agent write cannot mutate it mid-read.
331
371
  const progressState = newProgressCaptureState();
332
- const progressCaptureMode = setup.captureMode;
333
- const progressGitWorkspace = setup.gitWorkspace;
372
+ const casObserver = setup.casObserver;
373
+ const readObserverTouched = (): CasTouchedSnapshot => ({
374
+ before: new Map(casObserver.before),
375
+ blockedSecretPaths: new Set(casObserver.blockedSecretPaths),
376
+ });
377
+ // Git tree -> hybrid (numstat for tracked + observer for gitignored);
378
+ // non-git -> cas over the observer (no baseline tree needed). Undefined
379
+ // outside capture mode (writes are deny-gated, nothing is captured).
380
+ const progressSubstrate: ProgressSubstrate | undefined = !setup.captureMode
381
+ ? undefined
382
+ : setup.gitWorkspace
383
+ ? captureBaselineTree
384
+ ? createHybridProgressSubstrate(
385
+ createGitProgressSubstrate({
386
+ workspaceRoot: gitRoot,
387
+ executionId,
388
+ baselineTree: captureBaselineTree,
389
+ }),
390
+ createCasProgressSubstrate({ workspaceRoot: gitRoot, read: readObserverTouched }),
391
+ )
392
+ : undefined
393
+ : createCasProgressSubstrate({ workspaceRoot: gitRoot, read: readObserverTouched });
334
394
 
335
395
  const cancellationSignal = Context.current().cancellationSignal;
336
396
 
@@ -357,20 +417,20 @@ export function createDeepAgentActivities(config: Config) {
357
417
  globalBypass: setup.globalBypass,
358
418
  },
359
419
  streamVersion: setup.streamVersion,
360
- // Mid-run live capture (DD-32): attach file_change_progress before each
361
- // scheduled persist. Git capture mode only (a pinned baseline exists);
362
- // deep-agent writes no runner-owned gate files into the tree, so no
363
- // excludePaths matching its turn-boundary candidate capture.
420
+ // Mid-run live capture (DD-32 / DD-33): attach file_change_progress
421
+ // before each scheduled persist, throttled by the floor inside
422
+ // captureFileChangeProgress. The substrate (git / non-git CAS / hybrid)
423
+ // was chosen for this turn above; the deep-agent's only runner-owned
424
+ // tree entry is the `.stigmer` symlink, created before the baseline
425
+ // so it appears identically in every capture and cancels out of the
426
+ // diff — the git slice therefore needs no excludePaths, matching its
427
+ // turn-boundary candidate capture.
364
428
  beforePersist: async (status) => {
365
- if (!progressCaptureMode || !progressGitWorkspace || !captureBaselineTree) {
366
- return;
367
- }
429
+ if (!progressSubstrate) return;
368
430
  await captureFileChangeProgress({
369
431
  status,
370
- gitRoot,
371
- executionId,
372
432
  changeSetId,
373
- baselineTree: captureBaselineTree,
433
+ substrate: progressSubstrate,
374
434
  state: progressState,
375
435
  });
376
436
  },
@@ -429,6 +489,25 @@ export function createDeepAgentActivities(config: Config) {
429
489
  gitRoot,
430
490
  casCaptureClass,
431
491
  );
492
+ // Approved-command turn facts (DD-28): when every mutation-capable call
493
+ // this turn was a consented shell command, attach the provenance so the
494
+ // backend can verify the cited consent rows and auto-keep the set
495
+ // instead of arming a second review gate. Fail-closed: any non-qualifying
496
+ // turn attaches nothing and reviews manually exactly as before. Attached
497
+ // only when captureCandidateToLedger actually authors a CANDIDATE.
498
+ const commandProvenance = deriveTurnCommandProvenance({
499
+ status: initialStatus,
500
+ priorSettledToolCallIds,
501
+ priorSubAgentToolCallIds,
502
+ globalBypass: setup.globalBypass,
503
+ });
504
+ if (commandProvenance) {
505
+ console.log(
506
+ `[ExecuteDeepAgent] capture: turn qualifies for approved-command auto-keep ` +
507
+ `(consent rows: ${commandProvenance.consentToolCallIds.join(",") || "(auto_approve_all)"}); ` +
508
+ `attaching provenance to candidate (execution=${executionId})`,
509
+ );
510
+ }
432
511
  await captureCandidateToLedger({
433
512
  status: initialStatus,
434
513
  gitRoot,
@@ -441,6 +520,7 @@ export function createDeepAgentActivities(config: Config) {
441
520
  unreviewablePaths,
442
521
  unreviewableCaptureClass: casCaptureClass,
443
522
  gitWorkspace: setup.gitWorkspace,
523
+ commandProvenance,
444
524
  });
445
525
  // Review is pending iff a CANDIDATE was actually authored (the seam
446
526
  // drops no-op captures), so a turn that only touched-then-unchanged an
@@ -872,5 +952,12 @@ async function cleanup(setup: SetupResult | null): Promise<void> {
872
952
  console.warn("[ExecuteDeepAgent] MCP connection cleanup failed:", err);
873
953
  }
874
954
  }
955
+
956
+ // Drop the workspace→platform `.stigmer` symlink so an attached repo is
957
+ // left untouched between turns (issue #173 semantics; a multi-turn session
958
+ // recreates it next turn). Runs in the activity finally, BEFORE the
959
+ // workspace lock releases, so the next queued turn baselines a clean tree.
960
+ // Best-effort and symlink-only: a real `.stigmer` directory is never removed.
961
+ await removeStigmerSymlink(setup.workspaceBackend.rootDir);
875
962
  }
876
963
 
@@ -24,9 +24,11 @@ import {
24
24
  ExecutionPhase,
25
25
  MessageType,
26
26
  ToolCallStatus,
27
+ ToolKind,
27
28
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
28
29
  import { resolveApprovalMessage as resolveApprovalMsg } from "../../shared/approval-policy.js";
29
30
  import { classifyTool } from "../../shared/tool-kind.js";
31
+ import { applyTodoUpdate } from "../../shared/todos.js";
30
32
  import { ExecutionState } from "./execution-state.js";
31
33
  import { utcTimestamp } from "../../shared/status.js";
32
34
  import type { ExecutionStatusWriter } from "./execution-status-writer.js";
@@ -348,6 +350,13 @@ export class StatusBuilder {
348
350
  // (see v3 builder note). Truncating here would corrupt image base64.
349
351
  tc.status = ToolCallStatus.TOOL_CALL_COMPLETED;
350
352
  tc.result = extractToolResult(event.data);
353
+
354
+ // Project a completed to-do write into status.todos — success branch only,
355
+ // since a failed write_todos never ran its state-mutating Command. Shares
356
+ // the mapper and rationale documented in V3StatusBuilder.handleToolFinished.
357
+ if (tc.toolKind === ToolKind.TODO) {
358
+ applyTodoUpdate(this.state.proto.todos, tc.args?.todos, { merge: false });
359
+ }
351
360
  }
352
361
 
353
362
  tc.completedAt = utcTimestamp();
@@ -23,9 +23,11 @@ import {
23
23
  ExecutionPhase,
24
24
  MessageType,
25
25
  ToolCallStatus,
26
+ ToolKind,
26
27
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
27
28
  import { resolveApprovalMessage as resolveApprovalMsg } from "../../shared/approval-policy.js";
28
29
  import { classifyTool } from "../../shared/tool-kind.js";
30
+ import { applyTodoUpdate } from "../../shared/todos.js";
29
31
  import { ExecutionState } from "./execution-state.js";
30
32
  import { utcTimestamp } from "../../shared/status.js";
31
33
  import type { ExecutionStatusWriter } from "./execution-status-writer.js";
@@ -340,6 +342,17 @@ export class V3StatusBuilder implements ExecutionStatusWriter {
340
342
  this.state.toolStartTimes.delete(callId);
341
343
  this.toolArgBuffers.delete(callId);
342
344
 
345
+ // Project a completed to-do write into status.todos. deepagents' write_todos
346
+ // runs its state-mutating Command when the tool node COMPLETES, so we mirror
347
+ // it here (not at tool-start) — a call cancelled before finishing correctly
348
+ // projects nothing. Keyed on the harness-agnostic ToolKind.TODO (stamped at
349
+ // tool-start) and fed by the same shared mapper the Cursor tracker uses;
350
+ // deepagents always full-replaces (no merge field). The tool call itself
351
+ // stays in messages (the client filters ToolKind.TODO from the thread).
352
+ if (tc.toolKind === ToolKind.TODO) {
353
+ applyTodoUpdate(this.state.proto.todos, tc.args?.todos, { merge: false });
354
+ }
355
+
343
356
  this._forceNextUpdate = true;
344
357
  }
345
358
 
@@ -27,6 +27,7 @@ import { GitWriteBackMode } from "@stigmer/protos/ai/stigmer/agentic/session/v1/
27
27
  import type { WorkspaceEntry } from "@stigmer/protos/ai/stigmer/agentic/session/v1/workspace_pb";
28
28
  import type { WorkspaceBackend, ProvisionResult } from "../../shared/workspace/types.js";
29
29
  import { SourceType } from "../../shared/workspace/types.js";
30
+ import { gitCommitAsAgent } from "../../shared/workspace/git-identity.js";
30
31
  import type { ExecutionStatusWriter } from "./execution-status-writer.js";
31
32
 
32
33
  const WRITE_BACK_ENABLED_MODES = new Set([
@@ -273,7 +274,10 @@ export class WriteBackCoordinator {
273
274
  commitMsg: string,
274
275
  ): Promise<void> {
275
276
  await exec("git add -A");
276
- await exec(`git commit -m "${commitMsg}"`);
277
+ // Committed as the agent identity: the cloud sandbox has no git identity
278
+ // configured (a bare commit fails with "Author identity unknown"), and in
279
+ // local mode the agent's work should not be attributed to the host user.
280
+ await exec(gitCommitAsAgent(commitMsg));
277
281
 
278
282
  entryState.commitCount++;
279
283
  const shaOutput = await exec("git rev-parse HEAD");
package/src/otel.ts CHANGED
@@ -125,12 +125,20 @@ export async function startCursorTurnSpan(attrs: {
125
125
  [ATTR_SESSION_ID]: attrs.sessionId,
126
126
  },
127
127
  });
128
+ // Guard against a double end / a late setTokens: the turn span is ended from
129
+ // the activity's finally (finishTurnTelemetry), and OTel warns when a span is
130
+ // ended twice or mutated after end. Making this a no-op keeps the caller free
131
+ // to end idempotently on every exit path.
132
+ let ended = false;
128
133
  return {
129
134
  setTokens(input: number, output: number) {
135
+ if (ended) return;
130
136
  if (input) span.setAttribute(ATTR_LLM_INPUT_TOKENS, input);
131
137
  if (output) span.setAttribute(ATTR_LLM_OUTPUT_TOKENS, output);
132
138
  },
133
139
  end() {
140
+ if (ended) return;
141
+ ended = true;
134
142
  span.end();
135
143
  },
136
144
  };
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Unit tests for applyTodoUpdate — the harness-agnostic to-do map projection
3
+ * shared by the Cursor TodoTracker and the native v2/v3 status builders.
4
+ *
5
+ * These pin the exact mapping contract (status coercion, id synthesis,
6
+ * created_at preservation, merge vs. full-replace, clear-on-empty, defensive
7
+ * input handling, dirty return) so no two writers of status.todos can drift.
8
+ */
9
+
10
+ import { describe, it, expect } from "vitest";
11
+ import { TodoStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
12
+ import type { TodoItem } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/todo_pb";
13
+ import { applyTodoUpdate } from "../todos.js";
14
+
15
+ function emptyMap(): { [key: string]: TodoItem } {
16
+ return {};
17
+ }
18
+
19
+ describe("applyTodoUpdate", () => {
20
+ describe("status mapping", () => {
21
+ it("maps pending", () => {
22
+ const m = emptyMap();
23
+ applyTodoUpdate(m, [{ content: "A", status: "pending" }], { merge: false });
24
+ expect(m["todo-0"].status).toBe(TodoStatus.TODO_PENDING);
25
+ });
26
+
27
+ it("maps in_progress (snake_case)", () => {
28
+ const m = emptyMap();
29
+ applyTodoUpdate(m, [{ content: "A", status: "in_progress" }], { merge: false });
30
+ expect(m["todo-0"].status).toBe(TodoStatus.TODO_IN_PROGRESS);
31
+ });
32
+
33
+ it("maps inProgress (camelCase, via toLowerCase)", () => {
34
+ const m = emptyMap();
35
+ applyTodoUpdate(m, [{ content: "A", status: "inProgress" }], { merge: false });
36
+ expect(m["todo-0"].status).toBe(TodoStatus.TODO_IN_PROGRESS);
37
+ });
38
+
39
+ it("maps completed", () => {
40
+ const m = emptyMap();
41
+ applyTodoUpdate(m, [{ content: "A", status: "completed" }], { merge: false });
42
+ expect(m["todo-0"].status).toBe(TodoStatus.TODO_COMPLETED);
43
+ });
44
+
45
+ it("maps cancelled (Cursor superset)", () => {
46
+ const m = emptyMap();
47
+ applyTodoUpdate(m, [{ content: "A", status: "cancelled" }], { merge: false });
48
+ expect(m["todo-0"].status).toBe(TodoStatus.TODO_CANCELLED);
49
+ });
50
+
51
+ it("defaults unknown status to pending", () => {
52
+ const m = emptyMap();
53
+ applyTodoUpdate(m, [{ content: "A", status: "bogus" }], { merge: false });
54
+ expect(m["todo-0"].status).toBe(TodoStatus.TODO_PENDING);
55
+ });
56
+
57
+ it("defaults missing status to pending", () => {
58
+ const m = emptyMap();
59
+ applyTodoUpdate(m, [{ content: "A" }], { merge: false });
60
+ expect(m["todo-0"].status).toBe(TodoStatus.TODO_PENDING);
61
+ });
62
+ });
63
+
64
+ describe("id synthesis", () => {
65
+ it("uses a provided id", () => {
66
+ const m = emptyMap();
67
+ applyTodoUpdate(m, [{ id: "t1", content: "A", status: "pending" }], { merge: false });
68
+ expect(Object.keys(m)).toEqual(["t1"]);
69
+ expect(m["t1"].id).toBe("t1");
70
+ });
71
+
72
+ it("falls back to todo-<index> when id is absent", () => {
73
+ const m = emptyMap();
74
+ applyTodoUpdate(
75
+ m,
76
+ [
77
+ { content: "A", status: "pending" },
78
+ { content: "B", status: "completed" },
79
+ ],
80
+ { merge: false },
81
+ );
82
+ expect(Object.keys(m)).toEqual(["todo-0", "todo-1"]);
83
+ });
84
+
85
+ it("falls back to todo-<index> when id is an empty string", () => {
86
+ const m = emptyMap();
87
+ applyTodoUpdate(m, [{ id: "", content: "A", status: "pending" }], { merge: false });
88
+ expect(Object.keys(m)).toEqual(["todo-0"]);
89
+ });
90
+ });
91
+
92
+ describe("timestamps", () => {
93
+ it("sets created_at from raw.created_at on full-replace", () => {
94
+ const m = emptyMap();
95
+ applyTodoUpdate(
96
+ m,
97
+ [{ id: "t1", content: "A", status: "pending", created_at: "2026-01-01T00:00:00Z" }],
98
+ { merge: false, now: "2026-06-06T06:06:06Z" },
99
+ );
100
+ expect(m["t1"].createdAt).toBe("2026-01-01T00:00:00Z");
101
+ });
102
+
103
+ it("uses now for created_at when raw.created_at is absent (full-replace)", () => {
104
+ const m = emptyMap();
105
+ applyTodoUpdate(m, [{ content: "A", status: "pending" }], {
106
+ merge: false,
107
+ now: "2026-06-06T06:06:06Z",
108
+ });
109
+ expect(m["todo-0"].createdAt).toBe("2026-06-06T06:06:06Z");
110
+ });
111
+
112
+ it("always bumps updated_at to now", () => {
113
+ const m = emptyMap();
114
+ applyTodoUpdate(m, [{ content: "A", status: "pending" }], {
115
+ merge: false,
116
+ now: "2026-06-06T06:06:06Z",
117
+ });
118
+ expect(m["todo-0"].updatedAt).toBe("2026-06-06T06:06:06Z");
119
+ });
120
+
121
+ it("preserves created_at from the prior entry ONLY on merge", () => {
122
+ const m = emptyMap();
123
+ applyTodoUpdate(m, [{ id: "t1", content: "A", status: "pending" }], {
124
+ merge: false,
125
+ now: "2026-01-01T00:00:00Z",
126
+ });
127
+ applyTodoUpdate(m, [{ id: "t1", content: "A", status: "completed" }], {
128
+ merge: true,
129
+ now: "2026-02-02T00:00:00Z",
130
+ });
131
+ expect(m["t1"].createdAt).toBe("2026-01-01T00:00:00Z");
132
+ expect(m["t1"].updatedAt).toBe("2026-02-02T00:00:00Z");
133
+ expect(m["t1"].status).toBe(TodoStatus.TODO_COMPLETED);
134
+ });
135
+
136
+ it("does NOT preserve created_at on full-replace of the same id", () => {
137
+ const m = emptyMap();
138
+ applyTodoUpdate(m, [{ id: "t1", content: "A", status: "pending" }], {
139
+ merge: false,
140
+ now: "2026-01-01T00:00:00Z",
141
+ });
142
+ applyTodoUpdate(m, [{ id: "t1", content: "A", status: "pending" }], {
143
+ merge: false,
144
+ now: "2026-02-02T00:00:00Z",
145
+ });
146
+ expect(m["t1"].createdAt).toBe("2026-02-02T00:00:00Z");
147
+ });
148
+ });
149
+
150
+ describe("merge vs. full-replace", () => {
151
+ it("full-replace clears prior entries", () => {
152
+ const m = emptyMap();
153
+ applyTodoUpdate(m, [{ id: "a", content: "A", status: "pending" }], { merge: false });
154
+ applyTodoUpdate(m, [{ id: "b", content: "B", status: "pending" }], { merge: false });
155
+ expect(Object.keys(m)).toEqual(["b"]);
156
+ });
157
+
158
+ it("merge preserves other existing entries", () => {
159
+ const m = emptyMap();
160
+ applyTodoUpdate(m, [{ id: "a", content: "A", status: "pending" }], { merge: false });
161
+ applyTodoUpdate(m, [{ id: "b", content: "B", status: "pending" }], { merge: true });
162
+ expect(Object.keys(m).sort()).toEqual(["a", "b"]);
163
+ });
164
+
165
+ it("merge updates an existing entry by id", () => {
166
+ const m = emptyMap();
167
+ applyTodoUpdate(m, [{ id: "a", content: "A", status: "pending" }], { merge: false });
168
+ applyTodoUpdate(m, [{ id: "a", content: "A2", status: "completed" }], { merge: true });
169
+ expect(m["a"].content).toBe("A2");
170
+ expect(m["a"].status).toBe(TodoStatus.TODO_COMPLETED);
171
+ });
172
+ });
173
+
174
+ describe("clear-on-empty", () => {
175
+ it("clears the map on empty array + full-replace and reports a change", () => {
176
+ const m = emptyMap();
177
+ applyTodoUpdate(m, [{ id: "a", content: "A", status: "pending" }], { merge: false });
178
+ const changed = applyTodoUpdate(m, [], { merge: false });
179
+ expect(changed).toBe(true);
180
+ expect(Object.keys(m)).toHaveLength(0);
181
+ });
182
+
183
+ it("does NOT clear and reports no change on empty array + merge", () => {
184
+ const m = emptyMap();
185
+ applyTodoUpdate(m, [{ id: "a", content: "A", status: "pending" }], { merge: false });
186
+ const changed = applyTodoUpdate(m, [], { merge: true });
187
+ expect(changed).toBe(false);
188
+ expect(Object.keys(m)).toEqual(["a"]);
189
+ });
190
+
191
+ it("treats a non-array payload like empty (clears on full-replace)", () => {
192
+ const m = emptyMap();
193
+ applyTodoUpdate(m, [{ id: "a", content: "A", status: "pending" }], { merge: false });
194
+ const changed = applyTodoUpdate(m, undefined, { merge: false });
195
+ expect(changed).toBe(true);
196
+ expect(Object.keys(m)).toHaveLength(0);
197
+ });
198
+ });
199
+
200
+ describe("defensive input handling", () => {
201
+ it("coerces non-object array entries to empty todos", () => {
202
+ const m = emptyMap();
203
+ const changed = applyTodoUpdate(m, ["oops", null, 42], { merge: false });
204
+ expect(changed).toBe(true);
205
+ expect(Object.keys(m)).toEqual(["todo-0", "todo-1", "todo-2"]);
206
+ expect(m["todo-0"].content).toBe("");
207
+ expect(m["todo-0"].status).toBe(TodoStatus.TODO_PENDING);
208
+ });
209
+ });
210
+
211
+ describe("dirty return value", () => {
212
+ it("returns true when items are written", () => {
213
+ expect(applyTodoUpdate(emptyMap(), [{ content: "A" }], { merge: false })).toBe(true);
214
+ });
215
+ });
216
+ });