@gajae-code/coding-agent 0.17.4 → 0.17.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 (84) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/types/config/autorouting-tier-map.d.ts +1031 -3
  3. package/dist/types/config/file-lock.d.ts +4 -2
  4. package/dist/types/config/model-registry.d.ts +3 -1
  5. package/dist/types/config/model-resolver.d.ts +1 -1
  6. package/dist/types/exec/bash-executor.d.ts +2 -0
  7. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +11 -2
  8. package/dist/types/gjc-runtime/state-writer.d.ts +14 -1
  9. package/dist/types/harness-control-plane/control-endpoint.d.ts +4 -1
  10. package/dist/types/harness-control-plane/finalize.d.ts +14 -3
  11. package/dist/types/main.d.ts +1 -1
  12. package/dist/types/runtime-mcp/loader.d.ts +2 -0
  13. package/dist/types/runtime-mcp/manager.d.ts +13 -6
  14. package/dist/types/sdk/broker/broker.d.ts +4 -2
  15. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  16. package/dist/types/sdk/broker/identity.d.ts +2 -0
  17. package/dist/types/sdk/broker/managed-task-dag.d.ts +1335 -0
  18. package/dist/types/sdk/broker/managed-task-verification.d.ts +37 -0
  19. package/dist/types/sdk/broker/spawn-authority.d.ts +8 -0
  20. package/dist/types/sdk/broker/startup-failure.d.ts +4 -0
  21. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +5 -1
  22. package/dist/types/sdk/prompt-deadline-flush.d.ts +2 -0
  23. package/dist/types/sdk/session.d.ts +3 -3
  24. package/dist/types/session/agent-session.d.ts +7 -2
  25. package/dist/types/task/types.d.ts +2 -0
  26. package/dist/types/tools/bash-interactive.d.ts +1 -0
  27. package/dist/types/tools/fetch.d.ts +10 -1
  28. package/dist/types/tools/subagent.d.ts +8 -1
  29. package/package.json +7 -7
  30. package/src/cli/public-command-registry.ts +8 -2
  31. package/src/cli/setup-cli.ts +1 -0
  32. package/src/cli/stats-cli.ts +11 -1
  33. package/src/config/autorouting-tier-map.ts +529 -3
  34. package/src/config/file-lock.ts +59 -14
  35. package/src/config/model-discovery-manager.ts +4 -4
  36. package/src/config/model-profile-activation.ts +83 -23
  37. package/src/config/model-registry.ts +72 -12
  38. package/src/config/model-resolver.ts +6 -1
  39. package/src/coordinator-mcp/question-state.ts +4 -1
  40. package/src/coordinator-mcp/server.ts +14 -6
  41. package/src/exec/bash-executor.ts +3 -0
  42. package/src/extensibility/gjc-plugins/runtime-adapters.ts +46 -8
  43. package/src/gjc-runtime/state-writer.ts +317 -6
  44. package/src/harness-control-plane/control-endpoint.ts +24 -6
  45. package/src/harness-control-plane/finalize.ts +312 -25
  46. package/src/harness-control-plane/owner.ts +323 -89
  47. package/src/internal-urls/docs-index.generated.ts +6 -4
  48. package/src/main.ts +69 -19
  49. package/src/modes/acp/acp-agent.ts +581 -37
  50. package/src/modes/components/gjc-bundle-settings.ts +21 -1
  51. package/src/modes/components/tool-status-header.ts +72 -6
  52. package/src/modes/controllers/runtime-mcp-command-controller.ts +1 -1
  53. package/src/runtime-mcp/loader.ts +6 -3
  54. package/src/runtime-mcp/manager.ts +177 -75
  55. package/src/sdk/broker/broker.ts +1057 -150
  56. package/src/sdk/broker/ensure.ts +21 -3
  57. package/src/sdk/broker/identity.ts +13 -0
  58. package/src/sdk/broker/managed-task-dag.ts +1927 -0
  59. package/src/sdk/broker/managed-task-verification.ts +642 -0
  60. package/src/sdk/broker/spawn-authority.ts +140 -7
  61. package/src/sdk/broker/startup-failure.ts +80 -1
  62. package/src/sdk/broker/transport.ts +1 -0
  63. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  64. package/src/sdk/bus/index.ts +22 -6
  65. package/src/sdk/bus/telegram-daemon-contract.ts +5 -1
  66. package/src/sdk/cli/session-cli.ts +18 -0
  67. package/src/sdk/host/session-runtime.test.ts +46 -0
  68. package/src/sdk/host/session-runtime.ts +13 -6
  69. package/src/sdk/lifecycle/service.ts +2 -0
  70. package/src/sdk/prompt-deadline-flush.ts +26 -4
  71. package/src/sdk/session.ts +587 -115
  72. package/src/session/agent-session.ts +40 -12
  73. package/src/session/internal/managed-session-scope.ts +10 -0
  74. package/src/session/internal/managed-session-storage.ts +30 -1
  75. package/src/setup/provider-presets.json +2 -1
  76. package/src/slash-commands/builtin-registry.ts +1 -1
  77. package/src/task/executor.ts +46 -17
  78. package/src/task/types.ts +2 -0
  79. package/src/tools/bash-interactive.ts +2 -0
  80. package/src/tools/bash.ts +20 -0
  81. package/src/tools/fetch.ts +21 -5
  82. package/src/tools/subagent-render.ts +24 -1
  83. package/src/tools/subagent.ts +27 -1
  84. package/src/utils/mupdf.ts +11 -4
@@ -17,13 +17,21 @@ import { existsSync } from "node:fs";
17
17
  import type { AgentWireOwnerObservation } from "../modes/shared/agent-wire/event-contract";
18
18
  import { observeAgentWireFrame } from "../modes/shared/agent-wire/event-observation";
19
19
  import { classifyRecovery } from "./classifier";
20
- import { ControlServer, type EndpointHandler, type EndpointRequest } from "./control-endpoint";
21
- import { defaultFinalizeChecks, type FinalizeChecks, runFinalize, type ValidationCommandSpec } from "./finalize";
20
+ import { ControlServer, type EndpointHandler, type EndpointHandlerRequest } from "./control-endpoint";
21
+ import {
22
+ defaultFinalizeChecks,
23
+ type FinalizeChecks,
24
+ runFinalize,
25
+ type ValidationCommandSpec,
26
+ ValidationObservationUncertainError,
27
+ type ValidationRun,
28
+ } from "./finalize";
22
29
  import { type OperateResult, operate } from "./operate";
23
30
  import { preserveDirtyWorktree } from "./preserve";
24
31
  import { RECEIPT_SPOOL_DIR_ENV, withReceiptSpoolDir } from "./receipt-spool";
25
32
  import {
26
33
  buildReceipt,
34
+ type ReceiptEnvelope,
27
35
  type ReceiptSubject,
28
36
  requiresVanishBeforeAction,
29
37
  type ValidationEvidence,
@@ -50,6 +58,7 @@ import { buildStateView, nextAllowedActions, submitUnavailableReason } from "./s
50
58
  import {
51
59
  appendEvent,
52
60
  controlSocketPath,
61
+ type ReceiptIndexEntry,
53
62
  readEvents,
54
63
  readSessionState,
55
64
  sessionPaths,
@@ -165,6 +174,15 @@ export class RuntimeOwner {
165
174
  #framePumpFailure: unknown = null;
166
175
  #coalesced = new Map<string, true>();
167
176
  #stopPromise: Promise<void> | null = null;
177
+ #retiring = false;
178
+ #retirePromise: Promise<PrimitiveResponse> | null = null;
179
+ #lifecycleMutation: Promise<void> = Promise.resolve();
180
+ #activeRetirementSensitiveWork = new Set<Promise<void>>();
181
+ #activeValidationWork = new Set<{
182
+ controller: AbortController;
183
+ done: Promise<void>;
184
+ }>();
185
+ #validationStopFailure: unknown = null;
168
186
  #lastStopFailures: unknown[] = [];
169
187
  #reportedStopFailures = new Set<string>();
170
188
  #transportClosed = false;
@@ -270,10 +288,14 @@ export class RuntimeOwner {
270
288
  const state = await readSessionState(this.#opts.root, this.#opts.sessionId);
271
289
  if (!state) throw new Error(`session_not_found:${this.#opts.sessionId}`);
272
290
  const reconciled = reconcileLiveOwnerState(state);
273
- if (reconciled.reconciled) {
274
- await writeSessionState(this.#opts.root, reconciled.state);
275
- return reconciled.state;
276
- }
291
+ if (reconciled.reconciled)
292
+ return this.#withLifecycleMutation(async () => {
293
+ const latest = await readSessionState(this.#opts.root, this.#opts.sessionId);
294
+ if (!latest) throw new Error(`session_not_found:${this.#opts.sessionId}`);
295
+ const current = reconcileLiveOwnerState(latest);
296
+ if (current.reconciled) await this.#framePersistence.writeSessionState(this.#opts.root, current.state);
297
+ return current.state;
298
+ });
277
299
  return state;
278
300
  }
279
301
 
@@ -425,24 +447,158 @@ export class RuntimeOwner {
425
447
  return fn();
426
448
  }
427
449
 
428
- async #handle(req: EndpointRequest): Promise<unknown> {
450
+ async #withLifecycleMutation<T>(fn: () => Promise<T>): Promise<T> {
451
+ const previous = this.#lifecycleMutation;
452
+ let unlock!: () => void;
453
+ this.#lifecycleMutation = new Promise<void>(resolve => {
454
+ unlock = resolve;
455
+ });
456
+ await previous;
457
+ try {
458
+ return await fn();
459
+ } finally {
460
+ unlock();
461
+ }
462
+ }
463
+
464
+ #withRetirementSensitiveWork<T>(work: () => Promise<T>): Promise<T> {
465
+ const operation = work();
466
+ let done!: Promise<void>;
467
+ done = operation
468
+ .then(
469
+ () => undefined,
470
+ () => undefined,
471
+ )
472
+ .then(() => {
473
+ this.#activeRetirementSensitiveWork.delete(done);
474
+ });
475
+ this.#activeRetirementSensitiveWork.add(done);
476
+ return operation;
477
+ }
478
+
479
+ async #joinRetirementSensitiveWork(): Promise<void> {
480
+ await Promise.all([...this.#activeRetirementSensitiveWork]);
481
+ }
482
+
483
+ #withOwnedValidationWork<T>(requestSignal: AbortSignal, work: (signal: AbortSignal) => Promise<T>): Promise<T> {
484
+ const controller = new AbortController();
485
+ const done = Promise.withResolvers<void>();
486
+ const active = { controller, done: done.promise };
487
+ const abortFromRequest = (): void => controller.abort(requestSignal.reason);
488
+ if (requestSignal.aborted) abortFromRequest();
489
+ else requestSignal.addEventListener("abort", abortFromRequest, { once: true });
490
+ if (this.#stopPromise) controller.abort(new Error("owner_stopping"));
491
+ this.#activeValidationWork.add(active);
492
+ return (async () => {
493
+ try {
494
+ return await work(controller.signal);
495
+ } catch (error) {
496
+ if (controller.signal.aborted && !(error instanceof ValidationObservationUncertainError)) {
497
+ this.#validationStopFailure = error;
498
+ }
499
+ throw error;
500
+ } finally {
501
+ requestSignal.removeEventListener("abort", abortFromRequest);
502
+ this.#activeValidationWork.delete(active);
503
+ done.resolve();
504
+ }
505
+ })();
506
+ }
507
+
508
+ async #stopActiveValidationWork(): Promise<void> {
509
+ const active = [...this.#activeValidationWork];
510
+ for (const work of active) work.controller.abort(new Error("owner_stopping"));
511
+ await Promise.all(active.map(work => work.done));
512
+ if (this.#validationStopFailure !== null) {
513
+ throw new AggregateError([this.#validationStopFailure], "Runtime owner validation cleanup failed.");
514
+ }
515
+ }
516
+
517
+ async #persistValidationLifecycle(
518
+ signal: AbortSignal,
519
+ lifecycle: SessionState["lifecycle"],
520
+ blockers?: string[],
521
+ ): Promise<{ state: SessionState; persisted: boolean }> {
522
+ return this.#withLifecycleMutation(async () => {
523
+ const state = await readSessionState(this.#opts.root, this.#opts.sessionId);
524
+ if (!state) throw new Error(`session_not_found:${this.#opts.sessionId}`);
525
+ if (signal.aborted || state.lifecycle === "retired") {
526
+ return { state, persisted: false };
527
+ }
528
+ if (state.lifecycle === "completed") return { state, persisted: lifecycle === "completed" };
529
+ state.lifecycle = lifecycle;
530
+ if (blockers) state.blockers = blockers;
531
+ state.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
532
+ await writeSessionState(this.#opts.root, state);
533
+ return { state, persisted: true };
534
+ });
535
+ }
536
+
537
+ async #persistOwnedReceipt(
538
+ signal: AbortSignal,
539
+ family: "validation" | "completion" | "review-failure" | "review-verdict",
540
+ receipt: ReceiptEnvelope<unknown>,
541
+ ): Promise<ReceiptIndexEntry | null> {
542
+ return this.#withLifecycleMutation(async () => {
543
+ const state = await readSessionState(this.#opts.root, this.#opts.sessionId);
544
+ if (!state) throw new Error(`session_not_found:${this.#opts.sessionId}`);
545
+ if (
546
+ signal.aborted ||
547
+ this.#retiring ||
548
+ this.#stopPromise ||
549
+ state.lifecycle === "retired" ||
550
+ state.lifecycle === "completed"
551
+ ) {
552
+ return null;
553
+ }
554
+ return writeReceiptImmutable(this.#opts.root, this.#opts.sessionId, family, receipt.receiptId, receipt);
555
+ });
556
+ }
557
+
558
+ #finalizeChecksForOwner(checks: FinalizeChecks, signal: AbortSignal): FinalizeChecks {
559
+ return {
560
+ runValidation: (spec, validationSignal) => checks.runValidation(spec, validationSignal ?? signal),
561
+ resolveCommit: () => checks.resolveCommit(),
562
+ commitOnBranch: (commit, branch) => checks.commitOnBranch(commit, branch),
563
+ prOrIssue: () => checks.prOrIssue(),
564
+ writeReceipt: (family, receipt) => this.#persistOwnedReceipt(signal, family, receipt),
565
+ };
566
+ }
567
+
568
+ async #handle(req: EndpointHandlerRequest): Promise<unknown> {
569
+ if (this.#stopPromise && !this.#retiring && req.verb === "retire") {
570
+ return { ok: false, error: "owner_stopping" };
571
+ }
572
+ if (
573
+ (this.#stopPromise || this.#retiring) &&
574
+ (req.verb === "submit" ||
575
+ req.verb === "recover" ||
576
+ req.verb === "observe" ||
577
+ req.verb === "validate" ||
578
+ req.verb === "finalize" ||
579
+ req.verb === "operate")
580
+ ) {
581
+ return { ok: false, error: this.#retiring ? "owner_retiring" : "owner_stopping" };
582
+ }
429
583
  switch (req.verb) {
430
584
  case "ping":
431
585
  return { ok: true, ownerId: this.ownerId, leaseEpoch: this.#leaseEpoch };
432
586
  case "submit":
433
- return this.#submit(req.input);
587
+ return this.#withRetirementSensitiveWork(() => this.#submit(req.input));
434
588
  case "observe":
435
- return this.#observe();
589
+ return this.#withRetirementSensitiveWork(() => this.#observe());
436
590
  case "retire":
437
591
  return this.#retire();
438
592
  case "finalize":
439
- return this.#withReceiptSpoolFromInput(req.input, () => this.#finalize(req.input));
593
+ return this.#withReceiptSpoolFromInput(req.input, () => this.#finalize(req.input, req.signal));
440
594
  case "recover":
441
- return this.#withReceiptSpoolFromInput(req.input, () => this.#recover());
595
+ return this.#withRetirementSensitiveWork(() =>
596
+ this.#withReceiptSpoolFromInput(req.input, () => this.#recover()),
597
+ );
442
598
  case "validate":
443
- return this.#withReceiptSpoolFromInput(req.input, () => this.#validate());
599
+ return this.#withReceiptSpoolFromInput(req.input, () => this.#validate(req.signal));
444
600
  case "operate":
445
- return this.#withReceiptSpoolFromInput(req.input, () => this.#operate(req.input));
601
+ return this.#withReceiptSpoolFromInput(req.input, () => this.#operate(req.input, req.signal));
446
602
  default:
447
603
  return { ok: false, error: `owner_unsupported_verb:${req.verb}` };
448
604
  }
@@ -512,52 +668,79 @@ export class RuntimeOwner {
512
668
  };
513
669
  }
514
670
 
515
- async #validate(): Promise<PrimitiveResponse> {
516
- const state = await this.#loadState();
517
- if (state.handle.mode === "review") {
518
- // Review-only sessions do not run implementation validation and never attach PR metadata.
519
- state.lifecycle = "validating";
520
- state.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
521
- await writeSessionState(this.#opts.root, state);
522
- await this.#emit("info", "validated", { count: 0, reviewOnly: true });
523
- return this.#response(state, { validation: [], reviewOnly: true });
524
- }
525
- const checks = this.#finalizeChecks ?? defaultFinalizeChecks(state.handle.workspace);
526
- const commit = await checks.resolveCommit();
527
- const subject: ReceiptSubject = {
528
- workspace: state.handle.workspace,
529
- branch: state.handle.branch,
530
- head: commit,
531
- commit,
532
- };
533
- const validation: { name: string; valid: boolean; exitStatus: number }[] = [];
534
- for (const spec of this.#validationCommands ?? []) {
535
- const run = await checks.runValidation(spec);
536
- const evidence: ValidationEvidence = {
537
- command: spec.name,
538
- exactCommand: run.exactCommand,
539
- cwd: run.cwd,
540
- exitStatus: run.exitStatus,
541
- pass: run.pass,
542
- commitUnderTest: commit,
671
+ async #validate(requestSignal: AbortSignal): Promise<PrimitiveResponse> {
672
+ return this.#withOwnedValidationWork(requestSignal, async signal => {
673
+ const state = await this.#loadState();
674
+ const canceledResponse = async (evidence: Record<string, unknown>): Promise<PrimitiveResponse> => {
675
+ const latest = await readSessionState(this.#opts.root, this.#opts.sessionId);
676
+ if (!latest) throw new Error(`session_not_found:${this.#opts.sessionId}`);
677
+ return this.#response(latest, evidence, false);
543
678
  };
544
- const receipt = buildReceipt<ValidationEvidence>({
545
- receiptId: `val-${Date.now()}-${randomBytes(4).toString("hex")}`,
546
- sessionId: this.#opts.sessionId,
547
- family: "validation",
548
- source: "owner",
549
- subject,
550
- evidence,
551
- valid: run.pass,
552
- });
553
- await writeReceiptImmutable(this.#opts.root, this.#opts.sessionId, "validation", receipt.receiptId, receipt);
554
- validation.push({ name: spec.name, valid: validateReceipt(receipt).valid, exitStatus: run.exitStatus });
555
- }
556
- state.lifecycle = "validating";
557
- state.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
558
- await writeSessionState(this.#opts.root, state);
559
- await this.#emit("info", "validated", { count: validation.length });
560
- return this.#response(state, { validation });
679
+ if (signal.aborted) return canceledResponse({ canceled: true, validation: [] });
680
+ if (state.handle.mode === "review") {
681
+ // Review-only sessions do not run implementation validation and never attach PR metadata.
682
+ const updated = await this.#persistValidationLifecycle(signal, "validating");
683
+ if (updated.persisted) await this.#emit("info", "validated", { count: 0, reviewOnly: true });
684
+ return this.#response(updated.state, { validation: [], reviewOnly: true }, updated.persisted);
685
+ }
686
+ const checks = this.#finalizeChecks ?? defaultFinalizeChecks(state.handle.workspace);
687
+ const commit = await checks.resolveCommit();
688
+ if (signal.aborted) return canceledResponse({ canceled: true, validation: [] });
689
+ const subject: ReceiptSubject = {
690
+ workspace: state.handle.workspace,
691
+ branch: state.handle.branch,
692
+ head: commit,
693
+ commit,
694
+ };
695
+ const validation: { name: string; valid: boolean; exitStatus: number }[] = [];
696
+ for (const spec of this.#validationCommands ?? []) {
697
+ if (signal.aborted) return canceledResponse({ canceled: true, validation });
698
+ let run: ValidationRun;
699
+ try {
700
+ run = await checks.runValidation(spec, signal);
701
+ } catch (caught) {
702
+ if (caught instanceof ValidationObservationUncertainError) {
703
+ if (signal.aborted) return canceledResponse({ canceled: true, validation });
704
+ const updated = await this.#persistValidationLifecycle(signal, "blocked", [
705
+ `validation-unknown:${spec.name}`,
706
+ ]);
707
+ if (updated.persisted) {
708
+ await this.#emit("critical", "validation_uncertain", {
709
+ name: spec.name,
710
+ exactCommand: caught.exactCommand,
711
+ cwd: caught.cwd,
712
+ });
713
+ }
714
+ return this.#response(updated.state, { uncertain: true, name: spec.name, validation }, false);
715
+ }
716
+ throw caught;
717
+ }
718
+ if (signal.aborted) return canceledResponse({ canceled: true, validation });
719
+ const evidence: ValidationEvidence = {
720
+ command: spec.name,
721
+ exactCommand: run.exactCommand,
722
+ cwd: run.cwd,
723
+ exitStatus: run.exitStatus,
724
+ pass: run.pass,
725
+ commitUnderTest: commit,
726
+ };
727
+ const receipt = buildReceipt<ValidationEvidence>({
728
+ receiptId: `val-${Date.now()}-${randomBytes(4).toString("hex")}`,
729
+ sessionId: this.#opts.sessionId,
730
+ family: "validation",
731
+ source: "owner",
732
+ subject,
733
+ evidence,
734
+ valid: run.pass,
735
+ });
736
+ const entry = await this.#persistOwnedReceipt(signal, "validation", receipt);
737
+ if (!entry) return canceledResponse({ canceled: true, validation });
738
+ validation.push({ name: spec.name, valid: validateReceipt(receipt).valid, exitStatus: run.exitStatus });
739
+ }
740
+ const updated = await this.#persistValidationLifecycle(signal, "validating");
741
+ if (updated.persisted) await this.#emit("info", "validated", { count: validation.length });
742
+ return this.#response(updated.state, { validation }, updated.persisted);
743
+ });
561
744
  }
562
745
 
563
746
  async #recover(): Promise<PrimitiveResponse> {
@@ -610,26 +793,31 @@ export class RuntimeOwner {
610
793
  return this.#response(state, { decision, observation: recoveryObservation, vanishReceiptId });
611
794
  }
612
795
 
613
- async #operate(input: Record<string, unknown>): Promise<PrimitiveResponse> {
796
+ async #operate(input: Record<string, unknown>, requestSignal: AbortSignal): Promise<PrimitiveResponse> {
797
+ return this.#withOwnedValidationWork(requestSignal, signal => this.#operateOwned(input, signal));
798
+ }
799
+
800
+ async #operateOwned(input: Record<string, unknown>, signal: AbortSignal): Promise<PrimitiveResponse> {
614
801
  const goal = typeof input.goal === "string" ? input.goal : "";
615
- let state = await this.#loadState();
802
+ const state = await this.#loadState();
616
803
  if (!goal) return this.#response(state, { error: "empty-goal" }, false);
804
+ const baseChecks = this.#finalizeChecks ?? defaultFinalizeChecks(state.handle.workspace);
805
+ const finalizeChecks = this.#finalizeChecksForOwner(baseChecks, signal);
617
806
  const emitOperateEvent = async (
618
807
  severity: Severity,
619
808
  kind: string,
620
809
  evidence: Record<string, unknown>,
621
810
  ): Promise<void> => {
811
+ if (signal.aborted) return;
622
812
  if (kind === "operate_blocked" || kind === "operate_finalized") {
623
- const terminalState = await this.#loadState();
624
- terminalState.lifecycle =
625
- kind === "operate_finalized" && evidence.completed === true ? "completed" : "blocked";
626
- terminalState.blockers = Array.isArray(evidence.blockers)
813
+ const lifecycle = kind === "operate_finalized" && evidence.completed === true ? "completed" : "blocked";
814
+ const blockers = Array.isArray(evidence.blockers)
627
815
  ? evidence.blockers.filter((blocker): blocker is string => typeof blocker === "string")
628
- : terminalState.lifecycle === "completed"
816
+ : lifecycle === "completed"
629
817
  ? []
630
- : terminalState.blockers;
631
- terminalState.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
632
- await writeSessionState(this.#opts.root, terminalState);
818
+ : undefined;
819
+ const persisted = await this.#persistValidationLifecycle(signal, lifecycle, blockers);
820
+ if (!persisted.persisted) return;
633
821
  }
634
822
  await this.#emit(severity, kind, evidence);
635
823
  };
@@ -640,24 +828,30 @@ export class RuntimeOwner {
640
828
  branch: state.handle.branch ?? "",
641
829
  transport: this.#opts.transport,
642
830
  observe: () => this.#observeGit(),
643
- finalizeChecks: this.#finalizeChecks ?? defaultFinalizeChecks(state.handle.workspace),
831
+ finalizeChecks,
644
832
  validationCommands: this.#validationCommands,
645
833
  maxIterations: typeof input.maxIterations === "number" ? input.maxIterations : 5,
646
834
  emit: emitOperateEvent,
647
835
  });
648
- // Persist the loop's terminal lifecycle/blockers so the response state is not stale.
649
- state = await this.#loadState();
650
- state.lifecycle = result.lifecycle;
651
- state.blockers = result.blockers;
652
- state.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
653
- await writeSessionState(this.#opts.root, state);
654
- return this.#response(state, { operate: result }, result.completed);
836
+ if (signal.aborted) {
837
+ const latest = await readSessionState(this.#opts.root, this.#opts.sessionId);
838
+ if (!latest) throw new Error(`session_not_found:${this.#opts.sessionId}`);
839
+ return this.#response(latest, { canceled: true, operate: result }, false);
840
+ }
841
+ // Persist the loop's lifecycle/blockers from a fresh state under the same lock
842
+ // as retire so an older operation snapshot cannot resurrect terminal state.
843
+ const persisted = await this.#persistValidationLifecycle(signal, result.lifecycle, result.blockers);
844
+ return this.#response(persisted.state, { operate: result }, result.completed && persisted.persisted);
845
+ }
846
+
847
+ async #finalize(input: Record<string, unknown>, requestSignal: AbortSignal): Promise<PrimitiveResponse> {
848
+ return this.#withOwnedValidationWork(requestSignal, signal => this.#finalizeOwned(input, signal));
655
849
  }
656
850
 
657
- async #finalize(input: Record<string, unknown>): Promise<PrimitiveResponse> {
851
+ async #finalizeOwned(input: Record<string, unknown>, signal: AbortSignal): Promise<PrimitiveResponse> {
658
852
  const state = await this.#loadState();
659
853
  const workspace = state.handle.workspace;
660
- const checks = this.#finalizeChecks ?? defaultFinalizeChecks(workspace);
854
+ const checks = this.#finalizeChecksForOwner(this.#finalizeChecks ?? defaultFinalizeChecks(workspace), signal);
661
855
  const reviewOnly = state.handle.mode === "review";
662
856
  const inputVerdict = reviewOnly ? (typeof input.verdict === "string" ? input.verdict : null) : undefined;
663
857
  // Review-only finalize with no explicit verdict pulls the final assistant text from the live
@@ -666,6 +860,11 @@ export class RuntimeOwner {
666
860
  if (reviewOnly && inputVerdict == null && this.#opts.transport.getLastAssistantText) {
667
861
  assistantText = await this.#opts.transport.getLastAssistantText().catch(() => null);
668
862
  }
863
+ if (signal.aborted) {
864
+ const latest = await readSessionState(this.#opts.root, this.#opts.sessionId);
865
+ if (!latest) throw new Error(`session_not_found:${this.#opts.sessionId}`);
866
+ return this.#response(latest, { canceled: true }, false);
867
+ }
669
868
  const fin = await runFinalize({
670
869
  root: this.#opts.root,
671
870
  sessionId: this.#opts.sessionId,
@@ -680,18 +879,28 @@ export class RuntimeOwner {
680
879
  requirePr: input.requirePr !== false,
681
880
  validationCommands: this.#validationCommands,
682
881
  checks,
882
+ signal,
683
883
  clock: this.#opts.clock,
684
884
  });
685
- state.lifecycle = fin.completed ? "completed" : "blocked";
686
- state.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
687
- if (!fin.completed) state.blockers = fin.blockers;
688
- await writeSessionState(this.#opts.root, state);
885
+ if (signal.aborted) {
886
+ const latest = await readSessionState(this.#opts.root, this.#opts.sessionId);
887
+ if (!latest) throw new Error(`session_not_found:${this.#opts.sessionId}`);
888
+ return this.#response(latest, { canceled: true, finalize: fin }, false);
889
+ }
890
+ const persisted = await this.#persistValidationLifecycle(
891
+ signal,
892
+ fin.completed ? "completed" : "blocked",
893
+ fin.completed ? undefined : fin.blockers,
894
+ );
895
+ if (!persisted.persisted) {
896
+ return this.#response(persisted.state, { canceled: signal.aborted, finalize: fin }, false);
897
+ }
689
898
  await this.#emit(fin.completed ? "info" : "critical", "finalized", {
690
899
  completed: fin.completed,
691
900
  blockers: fin.blockers,
692
901
  ...(reviewOnly ? { verdict: fin.verdict ?? null, reviewOnly: true } : {}),
693
902
  });
694
- return this.#response(state, { finalize: fin }, fin.completed);
903
+ return this.#response(persisted.state, { finalize: fin }, fin.completed);
695
904
  }
696
905
 
697
906
  async #submit(input: Record<string, unknown>): Promise<PrimitiveResponse> {
@@ -775,13 +984,31 @@ export class RuntimeOwner {
775
984
  return this.#response(state, { observation, ownerRouted: true }, true, submitGateReason);
776
985
  }
777
986
 
778
- async #retire(): Promise<PrimitiveResponse> {
779
- const state = await this.#loadState();
780
- state.lifecycle = "retired";
781
- state.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
782
- await writeSessionState(this.#opts.root, state);
987
+ #retire(): Promise<PrimitiveResponse> {
988
+ if (this.#retirePromise) return this.#retirePromise;
989
+ this.#retiring = true;
990
+ this.#retirePromise = this.#retireOnce();
991
+ void this.#retirePromise.then(
992
+ () => queueMicrotask(() => void this.stop().catch(() => {})),
993
+ () => {},
994
+ );
995
+ return this.#retirePromise;
996
+ }
997
+
998
+ async #retireOnce(): Promise<PrimitiveResponse> {
999
+ // Fence new mutating work first, then abort validation and join every request
1000
+ // that could still commit state or a receipt before recording retirement.
1001
+ await this.#stopActiveValidationWork();
1002
+ await this.#joinRetirementSensitiveWork();
1003
+ const state = await this.#withLifecycleMutation(async () => {
1004
+ const current = await readSessionState(this.#opts.root, this.#opts.sessionId);
1005
+ if (!current) throw new Error(`session_not_found:${this.#opts.sessionId}`);
1006
+ current.lifecycle = "retired";
1007
+ current.updatedAt = new Date(this.#opts.clock ? this.#opts.clock() : Date.now()).toISOString();
1008
+ await writeSessionState(this.#opts.root, current);
1009
+ return current;
1010
+ });
783
1011
  await this.#emit("info", "owner_retired", {});
784
- queueMicrotask(() => void this.stop().catch(() => {}));
785
1012
  return this.#response(state, { retired: true });
786
1013
  }
787
1014
 
@@ -865,6 +1092,13 @@ export class RuntimeOwner {
865
1092
  const reportFailure = await this.#reportStopFailure(kind, error);
866
1093
  if (reportFailure !== null) failures.push(reportFailure);
867
1094
  };
1095
+ // A retirement already in progress must finish its state commit before stop
1096
+ // tears down the endpoint or relinquishes the lease.
1097
+ if (this.#retirePromise) await this.#retirePromise;
1098
+ // Validation owns independent subprocess groups. Cancel and join those exact
1099
+ // requests before transport/endpoint teardown can surrender the owner lease.
1100
+ await this.#stopActiveValidationWork();
1101
+ await this.#joinRetirementSensitiveWork();
868
1102
  const unsubscribe = this.#unsubscribeFrames;
869
1103
  if (unsubscribe) {
870
1104
  try {