@stigmer/runner 3.12.9 → 3.14.0-dev.20260910084630

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 (56) hide show
  1. package/LICENSE +190 -0
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/call-agent.d.ts +3 -2
  4. package/dist/activities/call-agent.js +13 -4
  5. package/dist/activities/call-agent.js.map +1 -1
  6. package/dist/activities/discover-mcp-server.js +1 -1
  7. package/dist/activities/execute-cursor/message-translator.d.ts +67 -0
  8. package/dist/activities/execute-cursor/message-translator.js +103 -0
  9. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  10. package/dist/activities/execute-cursor/prompt-builder.js +13 -1
  11. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  12. package/dist/activities/execute-cursor/turn-boundary.d.ts +16 -1
  13. package/dist/activities/execute-cursor/turn-boundary.js +44 -2
  14. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
  15. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +1 -1
  16. package/dist/activities/execute-deep-agent/attachment-injector.js +2 -2
  17. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  18. package/dist/client/stigmer-client.js +7 -2
  19. package/dist/client/stigmer-client.js.map +1 -1
  20. package/dist/ipc-protocol-fixtures.js +3 -4
  21. package/dist/ipc-protocol-fixtures.js.map +1 -1
  22. package/dist/ipc-protocol.js +5 -4
  23. package/dist/ipc-protocol.js.map +1 -1
  24. package/dist/runner-manager.js +1 -1
  25. package/dist/runner-manager.js.map +1 -1
  26. package/dist/runner.js +1 -1
  27. package/dist/runner.js.map +1 -1
  28. package/dist/shared/zip-extract.js +1 -1
  29. package/dist/shared/zip-extract.js.map +1 -1
  30. package/package.json +15 -4
  31. package/src/__tests__/ipc-protocol-fixtures.test.ts +3 -4
  32. package/src/__tests__/ipc-protocol.test.ts +3 -2
  33. package/src/activities/call-agent.ts +14 -4
  34. package/src/activities/discover-mcp-server.ts +1 -1
  35. package/src/activities/execute-cursor/__tests__/cursor-generate-image-live.test.ts +147 -0
  36. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +1 -1
  37. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +153 -1
  38. package/src/activities/execute-cursor/message-translator.ts +115 -0
  39. package/src/activities/execute-cursor/prompt-builder.ts +13 -1
  40. package/src/activities/execute-cursor/turn-boundary.ts +65 -1
  41. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +3 -2
  42. package/src/activities/execute-deep-agent/attachment-injector.ts +2 -2
  43. package/src/client/stigmer-client.ts +7 -2
  44. package/src/ipc-protocol-fixtures.ts +3 -4
  45. package/src/ipc-protocol.ts +5 -4
  46. package/src/runner-manager.ts +1 -1
  47. package/src/runner.ts +1 -1
  48. package/src/shared/__tests__/skill-mount.test.ts +1 -1
  49. package/src/shared/__tests__/skill-writer.test.ts +1 -1
  50. package/src/shared/__tests__/zip-extract.test.ts +1 -1
  51. package/src/shared/zip-extract.ts +1 -1
  52. package/dist/shared/zip-structure.d.ts +0 -61
  53. package/dist/shared/zip-structure.js +0 -128
  54. package/dist/shared/zip-structure.js.map +0 -1
  55. package/src/__test-utils__/zip-fixtures.ts +0 -206
  56. package/src/shared/zip-structure.ts +0 -181
@@ -32,7 +32,7 @@ import {
32
32
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
33
33
  import { captureBaselineToLedger } from "../capture-flow.js";
34
34
  import { denialLedgerPath } from "../approval-state.js";
35
- import { toolCallIdentityToken } from "../message-translator.js";
35
+ import { toolCallIdentityToken, UNRESOLVED_TOOL_CALL_ERROR } from "../message-translator.js";
36
36
  import { runTurnBoundary, type TurnBoundaryOptions } from "../turn-boundary.js";
37
37
 
38
38
  const execFileAsync = promisify(execFile);
@@ -405,3 +405,155 @@ describe("runTurnBoundary", () => {
405
405
  expect(shellCall.status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
406
406
  });
407
407
  });
408
+
409
+ // The issue #965 invariant: an unresolved tool must never silently complete.
410
+ // The production fixture is aex_01m1a6ww3nmp4952ar5v0g4g85 — Cursor's native
411
+ // `generateImage` (an interaction-channel tool no Stigmer seam touches) hung
412
+ // with no result event and no ledger entry, the turn completed, and the model's
413
+ // last words promised a write approval the platform never held. The boundary
414
+ // must settle such rows to an honest INTERRUPTED and put the platform's own
415
+ // disclosure on the transcript so the model's claim is never the last word.
416
+ describe("runTurnBoundary — unresolved tool calls on a completing turn (issue #965)", () => {
417
+ /** The incident's exact row shape: a streamed call that never resolved. */
418
+ function hangingGenerateImage(id: string): AgentMessage {
419
+ return create(AgentMessageSchema, {
420
+ type: MessageType.MESSAGE_AI,
421
+ toolCalls: [
422
+ create(ToolCallSchema, {
423
+ id,
424
+ name: "generateImage",
425
+ status: ToolCallStatus.TOOL_CALL_RUNNING,
426
+ args: { description: "a red circle", filePath: "red-circle.png" },
427
+ }),
428
+ ],
429
+ });
430
+ }
431
+
432
+ it("settles the incident shape to INTERRUPTED and discloses it (regression: aex_01m1a6ww)", async () => {
433
+ const status = newStatus();
434
+ const baseline = await captureBaselineToLedger({
435
+ status,
436
+ gitRoot: repo,
437
+ executionId: EXEC_ID,
438
+ changeSetId: CHANGE_SET_ID,
439
+ });
440
+ status.messages.push(hangingGenerateImage("tc-genimage-1"));
441
+
442
+ const result = await runTurnBoundary(boundaryOpts(status, baseline));
443
+
444
+ // The turn completes (no pause) — but not silently.
445
+ expect(result.waiting).toBe(false);
446
+ expect(result.settledUnresolvedCount).toBe(1);
447
+
448
+ const row = status.messages[0].toolCalls[0];
449
+ // INTERRUPTED, never FAILED: the one settled status a recovery replay may
450
+ // supersede (the #207 contract) — a FAILED stamp would freeze the row.
451
+ expect(row.status).toBe(ToolCallStatus.TOOL_CALL_INTERRUPTED);
452
+ expect(row.error).toBe(UNRESOLVED_TOOL_CALL_ERROR);
453
+ expect(row.isStreaming).toBe(false);
454
+ expect(row.completedAt).not.toBe("");
455
+
456
+ // The platform's disclosure is the transcript's last word — it names the
457
+ // tool and explicitly denies the phantom approval.
458
+ const last = status.messages[status.messages.length - 1];
459
+ expect(last.type).toBe(MessageType.MESSAGE_SYSTEM);
460
+ expect(last.content).toContain("generateImage");
461
+ expect(last.content).toContain("No approval is pending");
462
+ });
463
+
464
+ it("leaves non-terminal rows to the pause machinery on a PAUSING turn", async () => {
465
+ const status = newStatus();
466
+ const baseline = await captureBaselineToLedger({
467
+ status,
468
+ gitRoot: repo,
469
+ executionId: EXEC_ID,
470
+ changeSetId: CHANGE_SET_ID,
471
+ });
472
+ // A captured file change makes the turn pause…
473
+ await write("notes.md", "original notes\npaused-turn edit\n");
474
+ status.messages.push(
475
+ streamedEdit("tc-edit-1", "notes.md", "original notes\npaused-turn edit\n"),
476
+ );
477
+ // …while a hanging row rides the same turn.
478
+ status.messages.push(hangingGenerateImage("tc-genimage-2"));
479
+
480
+ const result = await runTurnBoundary(boundaryOpts(status, baseline));
481
+
482
+ expect(result.waiting).toBe(true);
483
+ expect(result.settledUnresolvedCount).toBe(0);
484
+ // Untouched by THIS sweep (a pausing turn's rows belong to the reconcile /
485
+ // collapse machinery, which has its own treatment for orphaned attempts).
486
+ expect(status.messages[1].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_RUNNING);
487
+ });
488
+
489
+ it("never settles a ledger-attributed row — that is the kinded machinery's call", async () => {
490
+ const status = newStatus();
491
+ const baseline = await captureBaselineToLedger({
492
+ status,
493
+ gitRoot: repo,
494
+ executionId: EXEC_ID,
495
+ changeSetId: CHANGE_SET_ID,
496
+ });
497
+ const secretWrite = create(ToolCallSchema, {
498
+ id: "tc-secret-1",
499
+ name: "edit",
500
+ status: ToolCallStatus.TOOL_CALL_PENDING,
501
+ args: { path: ".env", content: "API_KEY=x" },
502
+ });
503
+ status.messages.push(
504
+ create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, toolCalls: [secretWrite] }),
505
+ );
506
+ // A secret-kind hard-block entry: attributable (kind non-approval), so the
507
+ // row is accounted for and must NOT be swept as "unresolved".
508
+ await writeFile(
509
+ denialLedgerPath(hitlDir),
510
+ JSON.stringify({
511
+ toolName: "Write",
512
+ token: toolCallIdentityToken(secretWrite),
513
+ kind: "secret",
514
+ }) + "\n",
515
+ "utf-8",
516
+ );
517
+
518
+ const result = await runTurnBoundary(boundaryOpts(status, baseline));
519
+
520
+ expect(result.settledUnresolvedCount).toBe(0);
521
+ expect(secretWrite.status).toBe(ToolCallStatus.TOOL_CALL_PENDING);
522
+ });
523
+
524
+ it("scopes to THIS turn: seeded prior-turn rows and terminal rows are untouched", async () => {
525
+ const status = newStatus();
526
+ const baseline = await captureBaselineToLedger({
527
+ status,
528
+ gitRoot: repo,
529
+ executionId: EXEC_ID,
530
+ changeSetId: CHANGE_SET_ID,
531
+ });
532
+ // Message 0 is seeded prior-turn context (already adjudicated elsewhere).
533
+ status.messages.push(hangingGenerateImage("tc-prior-turn"));
534
+ // Message 1 opens this turn: one real terminal row.
535
+ status.messages.push(
536
+ create(AgentMessageSchema, {
537
+ type: MessageType.MESSAGE_AI,
538
+ toolCalls: [
539
+ create(ToolCallSchema, {
540
+ id: "tc-done",
541
+ name: "Read",
542
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
543
+ result: "file contents",
544
+ }),
545
+ ],
546
+ }),
547
+ );
548
+
549
+ const result = await runTurnBoundary(
550
+ boundaryOpts(status, baseline, { turnStartMessageIndex: 1 }),
551
+ );
552
+
553
+ expect(result.settledUnresolvedCount).toBe(0);
554
+ expect(status.messages[0].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_RUNNING);
555
+ expect(status.messages[1].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
556
+ // No disclosure was appended.
557
+ expect(status.messages.at(-1)?.type).toBe(MessageType.MESSAGE_AI);
558
+ });
559
+ });
@@ -1923,6 +1923,121 @@ export function detectUnattributedHookBlocks(
1923
1923
  return blocks;
1924
1924
  }
1925
1925
 
1926
+ /**
1927
+ * The honest terminal error stamped on a tool call that never resolved (issue
1928
+ * #965). Deliberately states all three negatives — not executed, not approved,
1929
+ * not denied — because the incident's harm was the model claiming an approval
1930
+ * was pending: this text is what the transcript shows INSTEAD of a spinner or
1931
+ * a silent after-the-fact interruption, and it must leave no room for an
1932
+ * approval-is-coming reading.
1933
+ */
1934
+ export const UNRESOLVED_TOOL_CALL_ERROR =
1935
+ "The tool did not return a result before the turn ended. It was never " +
1936
+ "executed, approved, or denied — no approval is pending for it.";
1937
+
1938
+ /** One never-resolved tool call the turn boundary settled (issue #965). */
1939
+ export interface UnresolvedToolCall {
1940
+ toolCallId: string;
1941
+ toolName: string;
1942
+ }
1943
+
1944
+ /**
1945
+ * Settle this-turn tool calls that are still NON-TERMINAL when a turn
1946
+ * completes without pausing — the issue #965 invariant, the sibling of
1947
+ * {@link detectUnattributedHookBlocks}' #205 invariant ("a blocked tool must
1948
+ * never silently complete" → "an unresolved tool must never silently
1949
+ * complete").
1950
+ *
1951
+ * THE SHAPE THIS CATCHES. A tool that hangs INSIDE the Cursor agent runtime —
1952
+ * the production case is `generateImage`, which rides the SDK's
1953
+ * interaction-query channel rather than the ordinary tool path — streams a
1954
+ * tool_call start, never streams a result, and is invisible to every Stigmer
1955
+ * seam: the hook never denied it (no ledger entry), so the reconcile never
1956
+ * gated it, and the turn completes with the row still PENDING/RUNNING. Before
1957
+ * this sweep, the server's terminal settle (issue #207) stamped such rows
1958
+ * TOOL_CALL_INTERRUPTED silently AFTER the runner reported completion — the
1959
+ * transcript's last word stayed whatever the model claimed, which in
1960
+ * aex_01m1a6ww3nmp4952ar5v0g4g85 was a promise that an approval was pending
1961
+ * when none existed.
1962
+ *
1963
+ * Settling here instead makes the runner the author of the honest record: the
1964
+ * row gets TOOL_CALL_INTERRUPTED with {@link UNRESOLVED_TOOL_CALL_ERROR}, and
1965
+ * the caller (turn-boundary.ts) appends a system disclosure naming what never
1966
+ * ran.
1967
+ *
1968
+ * WHY INTERRUPTED AND NEVER FAILED. TOOL_CALL_INTERRUPTED is deliberately the
1969
+ * one settled status the monotonic merge guard lets live execution evidence
1970
+ * supersede (see the guard's note in this file, ~line 331): if this FAILED
1971
+ * execution is later RECOVERED, the harness checkpoint may re-execute the call
1972
+ * under its original id, and the replayed events must be able to advance the
1973
+ * row to its true outcome. A boundary-stamped FAILED would freeze it forever.
1974
+ *
1975
+ * WHY IT NEVER FAILS THE RUN (unlike #205). A foreign hook block is provably
1976
+ * adversarial — approval semantics are permanently broken, so completing would
1977
+ * always be a lie. An unresolved row can also be benign stream event-loss
1978
+ * where the tool actually ran; failing the run would convert those into
1979
+ * regressions. Disclosure restores honesty at zero regression risk.
1980
+ *
1981
+ * Scope and exclusions, in order:
1982
+ * - THIS turn's parent-transcript rows only (from `turnStartMessageIndex`):
1983
+ * seeded prior-turn rows were adjudicated by their own execution's settle,
1984
+ * and sub-agent inner rows are the server settle's concern — the parent row
1985
+ * (e.g. the Task call) is what the user sees. Mirrors #205's scoping.
1986
+ * - Only PENDING/RUNNING rows: every terminal row was adjudicated, and
1987
+ * WAITING_APPROVAL rows belong to the pause machinery (the caller only runs
1988
+ * this sweep on a NON-pausing turn, so none should exist here anyway).
1989
+ * - Only rows with NO denial-ledger entry of ANY kind (exact token, then the
1990
+ * normalized-path fallback — the same two identities every sweep in this
1991
+ * file uses): a ledger-attributed row is the unattended/secret/fail-closed
1992
+ * machinery's to settle, not ours.
1993
+ *
1994
+ * Returns the settled calls so the boundary can disclose and log them.
1995
+ */
1996
+ export function settleUnresolvedToolCalls(
1997
+ messages: readonly AgentMessage[],
1998
+ turnStartMessageIndex: number,
1999
+ ledger: readonly DeniedLedgerEntry[],
2000
+ workspaceRoot?: string,
2001
+ ): UnresolvedToolCall[] {
2002
+ const ledgerTokens = new Set(ledger.map((e) => e.token));
2003
+ const ledgerNormalizedSalients = new Set<string>();
2004
+ if (workspaceRoot) {
2005
+ for (const entry of ledger) {
2006
+ const decoded = decodeIdentityToken(entry.token);
2007
+ if (!decoded) continue;
2008
+ const normalized = normalizedFileSalient(decoded.key, decoded.salient, workspaceRoot);
2009
+ if (normalized) ledgerNormalizedSalients.add(normalized);
2010
+ }
2011
+ }
2012
+
2013
+ const matchesLedger = (tc: ToolCall): boolean => {
2014
+ if (ledgerTokens.has(toolCallIdentityToken(tc))) return true;
2015
+ if (!workspaceRoot) return false;
2016
+ const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
2017
+ const normalized = normalizedFileSalient(id.key, id.salient, workspaceRoot);
2018
+ return !!normalized && ledgerNormalizedSalients.has(normalized);
2019
+ };
2020
+
2021
+ const settled: UnresolvedToolCall[] = [];
2022
+ for (const msg of messages.slice(Math.max(0, turnStartMessageIndex))) {
2023
+ for (const tc of msg.toolCalls) {
2024
+ if (
2025
+ tc.status !== ToolCallStatus.TOOL_CALL_PENDING &&
2026
+ tc.status !== ToolCallStatus.TOOL_CALL_RUNNING
2027
+ ) {
2028
+ continue;
2029
+ }
2030
+ if (matchesLedger(tc)) continue;
2031
+ tc.status = ToolCallStatus.TOOL_CALL_INTERRUPTED;
2032
+ tc.error = UNRESOLVED_TOOL_CALL_ERROR;
2033
+ tc.isStreaming = false;
2034
+ if (!tc.completedAt) tc.completedAt = utcTimestamp();
2035
+ settled.push({ toolCallId: tc.id, toolName: tc.name });
2036
+ }
2037
+ }
2038
+ return settled;
2039
+ }
2040
+
1926
2041
  /**
1927
2042
  * Stamp the tool calls the hook denied under UNATTENDED approval mode
1928
2043
  * (DD-014) as terminal TOOL_CALL_SKIPPED rows with UNATTENDED_SKIP
@@ -751,12 +751,24 @@ const TOOL_APPROVAL_PROTOCOL_INTRO =
751
751
  * well-behaved model otherwise concludes the environment is broken and tells the
752
752
  * user to "enable hooks in your Cursor settings", contradicting the approval
753
753
  * card. This rule reframes that signal as the gate working as designed.
754
+ *
755
+ * The fifth rule is the fourth's honesty boundary (issue #965): the gate
756
+ * recognition must be scoped to the PLATFORM'S OWN message texts ("blocked by
757
+ * a hook"; "submitted to the user for approval"), because a failure inside the
758
+ * harness itself can ALSO speak in permission vocabulary — the production case
759
+ * was Cursor's native generateImage hanging with a write-permission-flavored
760
+ * error, which the old any-"requires approval"-text reading turned into the
761
+ * model promising the user an approval card the platform never held. A failure
762
+ * without the platform's notice is an ordinary tool failure and must be
763
+ * reported as one; the platform's approval surface is never narrated into
764
+ * existence.
754
765
  */
755
766
  const TOOL_APPROVAL_PROTOCOL_RULES: readonly string[] = [
756
767
  "Carry out every action by calling the appropriate tool directly. Never describe an action you intend to take and then stop, and never ask the user for permission in prose.",
757
768
  "When an action needs approval, the platform pauses it, asks the user, and resumes you automatically after they decide. You do not request approval yourself — invoking the tool is how you request it.",
758
769
  "Even if a tool or MCP server instructs you to confirm with the user before acting (for example before sending, deleting, or purchasing), do NOT ask in prose. Invoke the tool and let the platform's approval step handle it.",
759
- "A tool result that says the action was blocked, denied, requires approval, or was \"blocked by a hook\" is the platform's approval gate doing its job — it is NOT an error and NOT a Cursor misconfiguration. Never tell the user to change Cursor settings, enable hooks, or fix their configuration; the gate is intentional and the platform will resume you automatically once the user decides.",
770
+ "A tool result that says it was \"blocked by a hook\" or that the action was \"submitted to the user for approval\" is the platform's approval gate doing its job — it is NOT an error and NOT a Cursor misconfiguration. Never tell the user to change Cursor settings, enable hooks, or fix their configuration; the gate is intentional, and for THESE results the platform will resume you automatically once the user decides.",
771
+ "Any other tool failure — including one that mentions permissions or approval but does not carry the platform's approval notice above — is an ordinary failure, not the approval gate. Report it to the user honestly as something that did not run. NEVER tell the user an approval is pending or that you will be resumed automatically unless the tool result carried the platform's approval notice; the platform shows its own approval prompts, and you must not invent one.",
760
772
  "If an action is declined, do not retry it or attempt a workaround for it; continue with the rest of the task.",
761
773
  ];
762
774
 
@@ -12,7 +12,12 @@
12
12
  * and redact the model's provisional post-denial narration;
13
13
  * 5. detect UNATTRIBUTED hook blocks (issue #205) — a tool blocked by a hook
14
14
  * with no ledger entry of any kind was denied by a FOREIGN hook the merge
15
- * preserved, and the caller fails the run rather than completing silently.
15
+ * preserved, and the caller fails the run rather than completing silently;
16
+ * 6. settle UNRESOLVED tool calls (issue #965) — a this-turn row still
17
+ * non-terminal on a completing turn with no ledger attribution hung inside
18
+ * the harness and can never complete; it is settled to an honest
19
+ * TOOL_CALL_INTERRUPTED and disclosed on the transcript instead of being
20
+ * silently stamped by the server's terminal settle after the fact.
16
21
  *
17
22
  * Extracted from the activity entry point (index.ts Phase 12) so it is directly
18
23
  * unit-testable AND re-enterable: the poisoned-handle / transport-timeout
@@ -51,9 +56,14 @@ import {
51
56
  clearProvisionalPostDenialNarration,
52
57
  detectUnattributedHookBlocks,
53
58
  reconcileDeniedToolCalls,
59
+ settleUnresolvedToolCalls,
54
60
  stampUnattendedSkippedToolCalls,
61
+ utcTimestamp,
55
62
  type UnattributedHookBlock,
56
63
  } from "./message-translator.js";
64
+ import { create } from "@bufbuild/protobuf";
65
+ import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
66
+ import { MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
57
67
 
58
68
  // How long the boundary waits for the first-denial-stop's run.cancel() to
59
69
  // settle before reading the final denial ledger and capturing the turn's tree.
@@ -140,6 +150,16 @@ export interface TurnBoundaryResult {
140
150
  * undone (a pausing turn is not silent — the caller logs and pauses as usual).
141
151
  */
142
152
  readonly unattributedHookBlocks: readonly UnattributedHookBlock[];
153
+ /**
154
+ * This-turn tool calls settled to TOOL_CALL_INTERRUPTED because they were
155
+ * still non-terminal on a completing turn with no ledger attribution (issue
156
+ * #965) — the harness returned no result for them and the platform holds no
157
+ * approval for them. Informational: the boundary already settled the rows
158
+ * and appended the transcript disclosure; the run is NOT failed for these
159
+ * (unlike #205's unattributed blocks, an unresolved row can be benign
160
+ * stream event-loss, so failing would over-punish).
161
+ */
162
+ readonly settledUnresolvedCount: number;
143
163
  }
144
164
 
145
165
  /**
@@ -338,6 +358,49 @@ export async function runTurnBoundary(opts: TurnBoundaryOptions): Promise<TurnBo
338
358
  primaryWorkspaceDir,
339
359
  );
340
360
  const waiting = deniedToolCalls.length > 0 || capturedChangeCount > 0;
361
+
362
+ // Issue #965 invariant: an unresolved tool must never silently complete.
363
+ // On a COMPLETING (non-pausing) turn, any this-turn row still PENDING /
364
+ // RUNNING with no ledger attribution hung inside the harness (the production
365
+ // case: `generateImage`, which rides the SDK's interaction-query channel and
366
+ // is invisible to the hook). Settle it to an honest TOOL_CALL_INTERRUPTED —
367
+ // never FAILED, so a recovery replay can still supersede it (#207) — and
368
+ // disclose it on the transcript, so the model's own narration (which may
369
+ // have promised an approval the platform does not hold) is never the last
370
+ // word. A PAUSING turn is skipped: its non-terminal rows belong to the
371
+ // reconcile/collapse machinery above. Runs AFTER the unattended stamp so a
372
+ // ledger-attributed row is already SKIPPED and cannot double-settle, and
373
+ // AFTER the #205 detection so a hook-block FAILED row keeps its distinct,
374
+ // run-failing treatment. Deliberately does NOT fail the run (unlike #205):
375
+ // an unresolved row can also be benign stream event-loss where the tool
376
+ // actually ran, and converting those into failures would be a regression.
377
+ let settledUnresolved: readonly { toolCallId: string; toolName: string }[] = [];
378
+ if (!waiting) {
379
+ settledUnresolved = settleUnresolvedToolCalls(
380
+ status.messages,
381
+ turnStartMessageIndex,
382
+ deniedLedger,
383
+ primaryWorkspaceDir,
384
+ );
385
+ if (settledUnresolved.length > 0) {
386
+ const names = [...new Set(settledUnresolved.map((s) => s.toolName))].join(", ");
387
+ status.messages.push(create(AgentMessageSchema, {
388
+ type: MessageType.MESSAGE_SYSTEM,
389
+ content:
390
+ `Note: the following tool call(s) never completed and were not executed: ${names}. ` +
391
+ `No approval is pending for them — if the agent said otherwise, disregard that. ` +
392
+ `You can ask the agent to try again.`,
393
+ timestamp: utcTimestamp(),
394
+ }));
395
+ console.warn(
396
+ `ExecuteCursor turn boundary: settled ${settledUnresolved.length} unresolved ` +
397
+ `tool call(s) to INTERRUPTED with disclosure [${names}] — the harness returned ` +
398
+ `no result for them and no ledger entry accounts for them (issue #965; ` +
399
+ `execution=${executionId})`,
400
+ );
401
+ }
402
+ }
403
+
341
404
  if (unattributedHookBlocks.length > 0) {
342
405
  const culprits = (foreignGatingHooks?.length ?? 0) > 0
343
406
  ? ` — likely foreign workspace hook(s): ${foreignGatingHooks!.join(", ")}`
@@ -366,5 +429,6 @@ export async function runTurnBoundary(opts: TurnBoundaryOptions): Promise<TurnBo
366
429
  capturedChangeCount,
367
430
  deniedToolCallCount: deniedToolCalls.length,
368
431
  unattributedHookBlocks,
432
+ settledUnresolvedCount: settledUnresolved.length,
369
433
  };
370
434
  }
@@ -14,7 +14,7 @@ import {
14
14
  } from "../attachment-injector.js";
15
15
  import { mockWorkspaceBackend } from "../../../__test-utils__/mock-workspace.js";
16
16
  import { makeInMemoryArtifactStorage } from "../../../__test-utils__/fake-artifact-storage.js";
17
- import { buildZip, type ZipFixtureFile } from "../../../__test-utils__/zip-fixtures.js";
17
+ import { buildZip, type ZipFixtureFile } from "@stigmer/zip-structure/testing";
18
18
  import {
19
19
  DEEP_AGENT_VISION_PROFILE,
20
20
  VisionBudget,
@@ -22,7 +22,8 @@ import {
22
22
 
23
23
  // ── ZIP Construction Helpers ─────────────────────────────────────────
24
24
  //
25
- // All archives come from the shared real-shape builder (zip-fixtures.ts):
25
+ // All archives come from the shared real-shape builder
26
+ // (@stigmer/zip-structure/testing):
26
27
  // local headers, payloads, central directory, EOCD — the only shape real
27
28
  // ZIP writers produce and the shape central-directory parsing requires.
28
29
  // The record-form helpers below keep ordinary call sites terse; tests that
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Security model: attachments are untrusted user uploads. ZIP archives are
9
9
  * parsed from the central directory — the format's authoritative index —
10
- * via the shared structural layer (shared/zip-structure.ts; issue #567,
10
+ * via the shared structural layer (@stigmer/zip-structure; issue #567,
11
11
  * which killed this module's local-header walk: it silently truncated
12
12
  * stored streaming entries and rejected Go-default archives outright).
13
13
  * Every entry is validated for path traversal, zip bombs, and format
@@ -48,7 +48,7 @@ import {
48
48
  EOCD_MIN_SIZE,
49
49
  parseZipStructure,
50
50
  type ZipStructuralEntry,
51
- } from "../../shared/zip-structure.js";
51
+ } from "@stigmer/zip-structure";
52
52
 
53
53
  // ── Constants ────────────────────────────────────────────────────────
54
54
 
@@ -234,7 +234,10 @@ export class StigmerClient {
234
234
  // scoped-token exchange itself requires the embedded_runner
235
235
  // bootstrap credential (a desktop runner's control-plane token is
236
236
  // the user's own Auth0 token, which the server correctly treats as
237
- // a browsing user).
237
+ // a browsing user); and the workflow child-execution create stamps
238
+ // the platform's workflow lineage labels, which cloud's
239
+ // reserved-label guard and environment composer accept only from
240
+ // runner-class callers — a user-token create is rejected outright.
238
241
  //
239
242
  // 3. Everything else uses the control-plane token. Falls through
240
243
  // unchanged when no runner token exists (OSS/local, where the
@@ -246,7 +249,9 @@ export class StigmerClient {
246
249
  const usesRunnerCredential =
247
250
  req.service.typeName === ExecutionContextQueryController.typeName ||
248
251
  (req.service.typeName === PlatformQueryController.typeName &&
249
- req.method.name === PlatformQueryController.method.getRunnerScopedToken.name);
252
+ req.method.name === PlatformQueryController.method.getRunnerScopedToken.name) ||
253
+ (req.service.typeName === AgentExecutionCommandController.typeName &&
254
+ req.method.name === AgentExecutionCommandController.method.create.name);
250
255
  const token =
251
256
  (usesRunnerCredential ? this.runnerTokenRef?.current : null)
252
257
  ?? this.tokenRef?.current
@@ -1,8 +1,7 @@
1
1
  // Golden wire-shape examples for the manager-mode IPC contract — one representative
2
- // instance per message. This is the single source the cross-language mirrors assert
3
- // against (Rust `protocol.rs`, Go `unified_runner.go`): the generator script serializes
4
- // `buildFixtures()` to `fixtures/ipc-protocol.generated.json`, and each mirror's tests
5
- // read that artifact. Because every sample is typed against an `Ipc*` interface from
2
+ // instance per message. This is the single source the cross-language mirror asserts
3
+ // against (Rust `protocol.rs`): the generator script serializes `buildFixtures()` to
4
+ // `fixtures/ipc-protocol.generated.json`, and the mirror's tests read that artifact. Because every sample is typed against an `Ipc*` interface from
6
5
  // `ipc-protocol.ts`, renaming or retyping a field there fails `tsc` here — that compile
7
6
  // error is what binds the fixtures to the contract. Full rules: docs/ipc-protocol.md.
8
7
 
@@ -1,9 +1,10 @@
1
1
  // Canonical machine-readable definition of the manager-mode IPC contract.
2
2
  // The runner emits these messages; the Rust host crate (crates/stigmer-runner-host/src/
3
- // protocol.rs) and the Go integration harness (unified_runner.go) hand-mirror them. Those
4
- // mirrors are kept honest by golden fixtures generated from this file via
5
- // ipc-protocol-fixtures.ts (run `make gen-ipc-fixtures`). Full spec and the rule for keeping
6
- // all definitions in sync: docs/ipc-protocol.md.
3
+ // protocol.rs) hand-mirrors them, kept honest by golden fixtures generated from this file
4
+ // via ipc-protocol-fixtures.ts (run `make gen-ipc-fixtures`). The conformance harness's
5
+ // manager-mode spawner (test/conformance/src/harness/runner-manager-process.ts) imports
6
+ // these types directly rather than mirroring them. Full spec and the rule for keeping all
7
+ // definitions in sync: docs/ipc-protocol.md.
7
8
 
8
9
  // Integer protocol version advertised in the `ready` handshake. Bump ONLY on a
9
10
  // breaking change (removed/renamed message, changed field type, changed lifecycle
@@ -789,7 +789,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
789
789
  ...createCallGrpcActivities(),
790
790
  ...createCallFunctionActivities(),
791
791
  ...createCallLlmActivities(),
792
- ...createCallAgentActivities(),
792
+ ...createCallAgentActivities(config),
793
793
  ...createCallAgentStatusActivities(),
794
794
  ...createRunCommandActivities(),
795
795
  ...createHydrateWorkflowActivities(config),
package/src/runner.ts CHANGED
@@ -524,7 +524,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
524
524
  ...createCallGrpcActivities(),
525
525
  ...createCallFunctionActivities(),
526
526
  ...createCallLlmActivities(),
527
- ...createCallAgentActivities(),
527
+ ...createCallAgentActivities(config),
528
528
  ...createCallAgentStatusActivities(),
529
529
  ...createRunCommandActivities(),
530
530
  ...createHydrateWorkflowActivities(config),
@@ -9,7 +9,7 @@ import {
9
9
  downloadArtifact,
10
10
  writeSkillMount,
11
11
  } from "../skill-mount.js";
12
- import { buildZip } from "../../__test-utils__/zip-fixtures.js";
12
+ import { buildZip } from "@stigmer/zip-structure/testing";
13
13
 
14
14
  // ─── Helpers ─────────────────────────────────────────────────────────────
15
15
 
@@ -12,7 +12,7 @@ import {
12
12
  } from "../skill-writer.js";
13
13
  import type { Skill } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
14
14
  import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
15
- import { buildZip } from "../../__test-utils__/zip-fixtures.js";
15
+ import { buildZip } from "@stigmer/zip-structure/testing";
16
16
 
17
17
  // ─── Helpers ─────────────────────────────────────────────────────────────
18
18
 
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { extractZipFileEntries, type ZipFileEntry } from "../zip-extract.js";
3
- import { buildZip } from "../../__test-utils__/zip-fixtures.js";
3
+ import { buildZip } from "@stigmer/zip-structure/testing";
4
4
 
5
5
  /**
6
6
  * Decode entries for text-content assertions. The production contract is
@@ -33,7 +33,7 @@
33
33
  */
34
34
 
35
35
  import { createInflateRaw } from "node:zlib";
36
- import { EOCD_MIN_SIZE, parseZipStructure, type ZipStructuralEntry } from "./zip-structure.js";
36
+ import { EOCD_MIN_SIZE, parseZipStructure, type ZipStructuralEntry } from "@stigmer/zip-structure";
37
37
 
38
38
  // ─── Public API ──────────────────────────────────────────────────────────
39
39
 
@@ -1,61 +0,0 @@
1
- /**
2
- * Policy-free structural ZIP parsing: end-of-central-directory (EOCD)
3
- * location plus central-directory walk, producing one record per entry.
4
- *
5
- * Parsing is *central-directory-based* — never a front-to-back walk of
6
- * local file headers. This is a correctness decision, not a style choice
7
- * (issues #450 and #567): local headers of streaming entries
8
- * (general-purpose flag bit 3) carry zeroed sizes, and the only
9
- * local-only way to recover them is scanning the payload for the
10
- * data-descriptor signature — which silently truncates any stored entry
11
- * whose *content* happens to contain those four bytes, and
12
- * desynchronizes every entry after it. The central directory always
13
- * carries the real sizes, and every consumer here holds the complete
14
- * archive bytes, so nothing a local-header walk could offer is needed.
15
- *
16
- * This layer maps bytes to entry records and nothing else. Policy — what
17
- * a structural failure means, which entries are acceptable, how payloads
18
- * are decoded — belongs to the consumers, and they differ on purpose:
19
- *
20
- * - shared/zip-extract.ts (skill artifacts): structural failure is
21
- * NON-FATAL — both editions' push gates validated every artifact
22
- * with a central-directory-based reader before storage, so a defect
23
- * here can only be a truncated or corrupted download.
24
- * - execute-deep-agent/attachment-injector.ts (user attachments):
25
- * structural failure is FAIL-HARD — the input is an untrusted
26
- * upload and nothing upstream vouched for it.
27
- *
28
- * ZIP64 is deliberately unsupported: both consumers cap input far below
29
- * every ZIP64 threshold (skill push gates: 100MB / 10,000 files;
30
- * attachment uploads: 10MB).
31
- *
32
- * Throws plain `Error`s on structural defects (no valid EOCD, a central
33
- * directory or local header record that does not parse, a payload slice
34
- * that runs past the end of the buffer); consumers translate those into
35
- * their own error models.
36
- */
37
- export interface ZipStructuralEntry {
38
- /** Entry path exactly as recorded in the central directory. */
39
- readonly name: string;
40
- readonly isDirectory: boolean;
41
- readonly compressionMethod: number;
42
- /**
43
- * The central directory's declared uncompressed size — authoritative for
44
- * pre-extraction accounting (e.g. ZIP-bomb budgeting), but still a
45
- * *declaration*: a consumer that distrusts its input must enforce it
46
- * against the actual decompressed output.
47
- */
48
- readonly uncompressedSize: number;
49
- /** The entry's raw payload slice (a view, not a copy) of the archive buffer. */
50
- readonly compressedData: Uint8Array;
51
- }
52
- /** Fixed size of the EOCD record, excluding the variable-length comment. */
53
- export declare const EOCD_MIN_SIZE = 22;
54
- /**
55
- * Enumerate the archive's entries from its central directory.
56
- *
57
- * Throws on any structural defect: no valid EOCD, a central directory or
58
- * local header record that doesn't parse, or a payload slice that runs
59
- * past the end of the buffer.
60
- */
61
- export declare function parseZipStructure(data: Uint8Array): ZipStructuralEntry[];