@gajae-code/coding-agent 0.11.0 → 0.11.1

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 (36) hide show
  1. package/CHANGELOG.md +9 -1
  2. package/dist/types/async/job-manager.d.ts +46 -7
  3. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +11 -0
  4. package/dist/types/modes/controllers/command-controller.d.ts +2 -2
  5. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -0
  6. package/dist/types/modes/interactive-mode.d.ts +2 -2
  7. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +1 -0
  8. package/dist/types/modes/types.d.ts +2 -2
  9. package/dist/types/sdk/broker/ensure.d.ts +9 -0
  10. package/dist/types/sdk/bus/discord-daemon.d.ts +5 -0
  11. package/dist/types/sdk/bus/telegram-daemon.d.ts +23 -0
  12. package/dist/types/tools/subagent-render.d.ts +1 -0
  13. package/dist/types/tools/subagent.d.ts +6 -1
  14. package/package.json +8 -8
  15. package/scripts/build-sdk-package-smoke.ts +5 -0
  16. package/src/async/job-manager.ts +406 -63
  17. package/src/gjc-runtime/ultragoal-guard.ts +55 -8
  18. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +102 -1
  19. package/src/gjc-runtime/ultragoal-runtime.ts +72 -8
  20. package/src/internal-urls/docs-index.generated.ts +2 -2
  21. package/src/modes/controllers/command-controller.ts +9 -15
  22. package/src/modes/controllers/extension-ui-controller.ts +48 -24
  23. package/src/modes/interactive-mode.ts +28 -16
  24. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +7 -3
  25. package/src/modes/types.ts +2 -2
  26. package/src/sdk/broker/discovery.ts +20 -1
  27. package/src/sdk/broker/ensure.ts +82 -24
  28. package/src/sdk/bus/chat-effect-journal.ts +6 -4
  29. package/src/sdk/bus/discord-daemon.ts +25 -8
  30. package/src/sdk/bus/index.ts +57 -21
  31. package/src/sdk/bus/native-runtime-compatibility.ts +1 -1
  32. package/src/sdk/bus/telegram-daemon.ts +102 -10
  33. package/src/session/agent-session.ts +176 -29
  34. package/src/task/index.ts +26 -16
  35. package/src/tools/subagent-render.ts +19 -8
  36. package/src/tools/subagent.ts +84 -32
@@ -75,6 +75,47 @@ export type SubagentRunOutcome = { kind: "completed"; text: string } | { kind: "
75
75
  /** Canonical lifecycle of a subagent across pause/resume cycles. */
76
76
  export type SubagentLifecycle = "running" | "paused" | "queued" | "completed" | "failed" | "cancelled";
77
77
 
78
+ /** Maximum time allowed to prove owned subagents have stopped before replacement. */
79
+ export const OWNER_SUBAGENT_SHUTDOWN_TIMEOUT_MS = 5_000;
80
+
81
+ export class OwnerSubagentShutdownError extends Error {
82
+ readonly code = "owner_shutdown_in_progress";
83
+
84
+ constructor() {
85
+ super("Cannot start subagent while owner shutdown is in progress.");
86
+ this.name = "OwnerSubagentShutdownError";
87
+ }
88
+ }
89
+
90
+ export interface OwnerSubagentShutdownTarget {
91
+ subagentId: string;
92
+ jobId: string | null;
93
+ source: "record" | "metadata_job";
94
+ }
95
+
96
+ export interface OwnerSubagentShutdownLease {
97
+ ownerId: string;
98
+ id: string;
99
+ targets: readonly OwnerSubagentShutdownTarget[];
100
+ }
101
+
102
+ export interface OwnerSubagentShutdownProof {
103
+ ownerId: string;
104
+ leaseId: string;
105
+ confirmed: boolean;
106
+ reason: "confirmed" | "deadline_exceeded" | "missing_terminal_evidence" | "lease_lost";
107
+ targets: readonly OwnerSubagentShutdownTarget[];
108
+ terminalIds: readonly string[];
109
+ unresolvedIds: readonly string[];
110
+ }
111
+
112
+ interface OwnerSubagentShutdownLeaseState {
113
+ lease: OwnerSubagentShutdownLease;
114
+ backingJobIds: ReadonlyMap<string, readonly string[]>;
115
+ phase: "active" | "proving" | "proved";
116
+ proof?: OwnerSubagentShutdownProof;
117
+ }
118
+
78
119
  /**
79
120
  * Live, executor-owned control handle for a RUNNING subagent. Registered when a
80
121
  * subagent run starts and removed on pause/terminal so a paused subagent retains
@@ -122,6 +163,12 @@ export interface ResumeDescriptor {
122
163
  data: unknown;
123
164
  }
124
165
 
166
+ /**
167
+ * In-memory resume runner bound to the session that originally launched a
168
+ * subagent. Never serialized: process restart drops it so resume fails closed.
169
+ */
170
+ export type ResumeRunner = (subagentId: string, message?: string, descriptor?: ResumeDescriptor) => string | undefined;
171
+
125
172
  function sessionFileFromResumeDescriptorData(data: unknown): string | null {
126
173
  if (typeof data !== "object" || data === null) return null;
127
174
  const sessionFile = (data as { sessionFile?: unknown }).sessionFile;
@@ -324,9 +371,19 @@ export class AsyncJobManager {
324
371
  readonly #subagentProgress = new Map<string, AgentProgress>();
325
372
  readonly #resumeQueue: ResumeQueueEntry[] = [];
326
373
  #resumeSeq = 0;
327
- #resumeRunner?: (subagentId: string, message?: string, descriptor?: ResumeDescriptor) => string | undefined;
374
+ #resumeRunner?: ResumeRunner;
328
375
  readonly #resumeDescriptors = new Map<string, ResumeDescriptor>();
376
+ /**
377
+ * Per-descriptor in-memory resume runners, keyed by subagentId, captured at
378
+ * registerResumeDescriptor time so each resume executes under the authority of
379
+ * the session that originally launched that subagent. Fixes #2303's global
380
+ * last-writer-wins slot. In-memory only: a process restart drops these and
381
+ * resume fails closed with reason "no_runner".
382
+ */
383
+ readonly #descriptorResumeRunners = new Map<string, ResumeRunner>();
329
384
  readonly #deadLetteredDeliveries = new Map<string, AsyncJobDelivery>();
385
+ readonly #ownerSubagentShutdownLeases = new Map<string, OwnerSubagentShutdownLeaseState>();
386
+ #ownerSubagentShutdownSeq = 0;
330
387
  #lastDisposeDiagnostics: AsyncJobDisposeDiagnostics = { stuckJobIds: [], deliveriesDrained: true };
331
388
  /**
332
389
  * Change listeners notified on any mutation that can alter the live job set
@@ -387,6 +444,9 @@ export class AsyncJobManager {
387
444
  if (this.#disposed) {
388
445
  throw new Error("Async job manager is disposed");
389
446
  }
447
+ if (options?.ownerId && this.#isOwnerSubagentShutdownFenced(options.ownerId)) {
448
+ throw new OwnerSubagentShutdownError();
449
+ }
390
450
  const runningCount = this.getRunningJobs().length;
391
451
  if (runningCount >= this.#maxRunningJobs) {
392
452
  throw new Error(
@@ -687,14 +747,23 @@ export class AsyncJobManager {
687
747
  }
688
748
 
689
749
  /** Install the TaskTool-owned resume runner. Returns the new job id, or undefined on failure. */
690
- setResumeRunner(
691
- runner: (subagentId: string, message?: string, descriptor?: ResumeDescriptor) => string | undefined,
692
- ): void {
750
+ setResumeRunner(runner: ResumeRunner): void {
693
751
  this.#resumeRunner = runner;
694
752
  }
695
753
 
696
- registerResumeDescriptor(descriptor: ResumeDescriptor): void {
754
+ registerResumeDescriptor(descriptor: ResumeDescriptor, runner?: ResumeRunner): void {
697
755
  this.#resumeDescriptors.set(descriptor.subagentId, descriptor);
756
+ if (runner) this.#descriptorResumeRunners.set(descriptor.subagentId, runner);
757
+ }
758
+
759
+ /**
760
+ * Resolve the resume runner for a subagent: prefer the per-descriptor runner
761
+ * captured at registration time (the originating parent's execution authority),
762
+ * falling back to the process-global runner only for descriptors registered
763
+ * without one.
764
+ */
765
+ #resolveResumeRunner(subagentId: string): ResumeRunner | undefined {
766
+ return this.#descriptorResumeRunners.get(subagentId) ?? this.#resumeRunner;
698
767
  }
699
768
 
700
769
  getResumeDescriptor(subagentId: string, filter?: AsyncJobFilter): ResumeDescriptor | undefined {
@@ -704,6 +773,224 @@ export class AsyncJobManager {
704
773
  return descriptor;
705
774
  }
706
775
 
776
+ #isOwnerSubagentShutdownFenced(ownerId: string | undefined): boolean {
777
+ return ownerId !== undefined && this.#ownerSubagentShutdownLeases.has(ownerId);
778
+ }
779
+
780
+ #isTerminalSubagentStatus(status: SubagentLifecycle): boolean {
781
+ return status === "completed" || status === "failed" || status === "cancelled";
782
+ }
783
+
784
+ beginOwnerSubagentShutdown(ownerId: string): OwnerSubagentShutdownLease | undefined {
785
+ if (!ownerId || this.#ownerSubagentShutdownLeases.has(ownerId)) return undefined;
786
+ const targets = new Map<string, OwnerSubagentShutdownTarget>();
787
+ const backingJobIds = new Map<string, Set<string>>();
788
+ const addBackingJob = (subagentId: string, jobId: string | null): void => {
789
+ if (!jobId) return;
790
+ const ids = backingJobIds.get(subagentId) ?? new Set<string>();
791
+ ids.add(jobId);
792
+ backingJobIds.set(subagentId, ids);
793
+ };
794
+ for (const record of this.#subagentRecords.values()) {
795
+ if (record.ownerId !== ownerId || this.#isTerminalSubagentStatus(record.status)) continue;
796
+ targets.set(record.subagentId, {
797
+ subagentId: record.subagentId,
798
+ jobId: record.status === "queued" ? null : record.currentJobId,
799
+ source: "record",
800
+ });
801
+ if (record.status !== "queued") addBackingJob(record.subagentId, record.currentJobId);
802
+ }
803
+ for (const job of this.#jobs.values()) {
804
+ const subagentId = job.metadata?.subagent?.id;
805
+ if (
806
+ job.ownerId !== ownerId ||
807
+ !subagentId ||
808
+ (job.status !== "running" && job.status !== "paused" && job.status !== "cancelled")
809
+ ) {
810
+ continue;
811
+ }
812
+ if (!targets.has(subagentId)) {
813
+ targets.set(subagentId, { subagentId, jobId: job.id, source: "metadata_job" });
814
+ }
815
+ addBackingJob(subagentId, job.id);
816
+ }
817
+ const lease: OwnerSubagentShutdownLease = {
818
+ ownerId,
819
+ id: `owner_shutdown_${++this.#ownerSubagentShutdownSeq}`,
820
+ targets: Array.from(targets.values()),
821
+ };
822
+ this.#ownerSubagentShutdownLeases.set(ownerId, {
823
+ lease,
824
+ backingJobIds: new Map<string, readonly string[]>(
825
+ Array.from(backingJobIds, ([subagentId, jobIds]): [string, readonly string[]] => [
826
+ subagentId,
827
+ Array.from(jobIds),
828
+ ]),
829
+ ),
830
+ phase: "active",
831
+ });
832
+ return lease;
833
+ }
834
+
835
+ runOwnerProducerCleanups(filter?: AsyncJobFilter): void {
836
+ this.#runOwnerProducerCleanups(filter, false);
837
+ }
838
+
839
+ runOwnerProducerCleanupsStrict(filter?: AsyncJobFilter): void {
840
+ this.#runOwnerProducerCleanups(filter, true);
841
+ }
842
+
843
+ #runOwnerProducerCleanups(filter: AsyncJobFilter | undefined, strict: boolean): void {
844
+ const ownerId = filter?.ownerId;
845
+ const targets: Array<[string, Set<() => void>]> = [];
846
+ if (ownerId) {
847
+ const bag = this.#ownerCleanups.get(ownerId);
848
+ if (bag) targets.push([ownerId, bag]);
849
+ } else {
850
+ for (const entry of this.#ownerCleanups.entries()) targets.push(entry);
851
+ }
852
+ const errors: unknown[] = [];
853
+ for (const [id, bag] of targets) {
854
+ const callbacks = Array.from(bag);
855
+ bag.clear();
856
+ this.#ownerCleanups.delete(id);
857
+ for (const cleanup of callbacks) {
858
+ try {
859
+ cleanup();
860
+ } catch (error) {
861
+ errors.push(error);
862
+ if (strict) {
863
+ let retryBag = this.#ownerCleanups.get(id);
864
+ if (!retryBag) {
865
+ retryBag = new Set();
866
+ this.#ownerCleanups.set(id, retryBag);
867
+ }
868
+ retryBag.add(cleanup);
869
+ }
870
+ logger.warn("Async job owner cleanup failed", {
871
+ ownerId: id,
872
+ error: error instanceof Error ? error.message : String(error),
873
+ });
874
+ }
875
+ }
876
+ }
877
+ if (strict && errors.length > 0) throw new AggregateError(errors, "Async job owner cleanup failed");
878
+ }
879
+
880
+ async cancelAndProveOwnerSubagents(
881
+ lease: OwnerSubagentShutdownLease,
882
+ options?: { timeoutMs?: number },
883
+ ): Promise<OwnerSubagentShutdownProof> {
884
+ const state = this.#ownerSubagentShutdownLeases.get(lease.ownerId);
885
+ if (!state || state.lease.id !== lease.id) {
886
+ return this.#ownerSubagentShutdownProof(
887
+ lease,
888
+ "lease_lost",
889
+ lease.targets.map(target => target.subagentId),
890
+ );
891
+ }
892
+ if (state.phase === "proved" && state.proof) return state.proof;
893
+ state.phase = "proving";
894
+ const settled = new Set<string>();
895
+ const promises: Promise<void>[] = [];
896
+ for (const target of lease.targets) {
897
+ const backingJobs = (state.backingJobIds.get(target.subagentId) ?? []).map(jobId => ({
898
+ jobId,
899
+ job: this.#jobs.get(jobId),
900
+ }));
901
+ if (target.source === "record") this.cancelSubagent(target.subagentId, { ownerId: lease.ownerId });
902
+ for (const { jobId, job } of backingJobs) {
903
+ this.cancel(jobId, { ownerId: lease.ownerId });
904
+ if (!job || job.ownerId !== lease.ownerId) continue;
905
+ promises.push(
906
+ job.promise.then(
907
+ () => {
908
+ settled.add(jobId);
909
+ },
910
+ () => {
911
+ settled.add(jobId);
912
+ },
913
+ ),
914
+ );
915
+ }
916
+ }
917
+ const timeoutMs = Math.max(0, options?.timeoutMs ?? OWNER_SUBAGENT_SHUTDOWN_TIMEOUT_MS);
918
+ let deadlineExceeded = false;
919
+ await Promise.race([
920
+ Promise.allSettled(promises),
921
+ Bun.sleep(timeoutMs).then(() => {
922
+ deadlineExceeded = true;
923
+ }),
924
+ ]);
925
+ const current = this.#ownerSubagentShutdownLeases.get(lease.ownerId);
926
+ if (!current || current.lease.id !== lease.id || current.phase !== "proving") {
927
+ return this.#ownerSubagentShutdownProof(
928
+ lease,
929
+ "lease_lost",
930
+ lease.targets.map(target => target.subagentId),
931
+ );
932
+ }
933
+ const unresolvedIds = lease.targets
934
+ .filter(target => !this.#hasTerminalShutdownEvidence(target, lease.ownerId, current.backingJobIds, settled))
935
+ .map(target => target.subagentId);
936
+ const reason =
937
+ unresolvedIds.length === 0
938
+ ? "confirmed"
939
+ : deadlineExceeded
940
+ ? "deadline_exceeded"
941
+ : "missing_terminal_evidence";
942
+ const proof = this.#ownerSubagentShutdownProof(lease, reason, unresolvedIds);
943
+ current.phase = "proved";
944
+ current.proof = proof;
945
+ return proof;
946
+ }
947
+
948
+ #hasTerminalShutdownEvidence(
949
+ target: OwnerSubagentShutdownTarget,
950
+ ownerId: string,
951
+ backingJobIds: ReadonlyMap<string, readonly string[]>,
952
+ settled: ReadonlySet<string>,
953
+ ): boolean {
954
+ const record = this.#subagentRecords.get(target.subagentId);
955
+ if (!record || record.ownerId !== ownerId || !this.#isTerminalSubagentStatus(record.status)) return false;
956
+ return (backingJobIds.get(target.subagentId) ?? []).every(jobId => {
957
+ if (!settled.has(jobId)) return false;
958
+ const job = this.#jobs.get(jobId);
959
+ return job === undefined || job.ownerId === ownerId;
960
+ });
961
+ }
962
+
963
+ #ownerSubagentShutdownProof(
964
+ lease: OwnerSubagentShutdownLease,
965
+ reason: OwnerSubagentShutdownProof["reason"],
966
+ unresolvedIds: readonly string[],
967
+ ): OwnerSubagentShutdownProof {
968
+ const unresolved = new Set(unresolvedIds);
969
+ return {
970
+ ownerId: lease.ownerId,
971
+ leaseId: lease.id,
972
+ confirmed: reason === "confirmed",
973
+ reason,
974
+ targets: lease.targets,
975
+ terminalIds: lease.targets
976
+ .filter(target => !unresolved.has(target.subagentId))
977
+ .map(target => target.subagentId),
978
+ unresolvedIds: [...unresolvedIds],
979
+ };
980
+ }
981
+
982
+ finishOwnerSubagentShutdown(lease: OwnerSubagentShutdownLease, outcome: "commit" | "release"): void {
983
+ const state = this.#ownerSubagentShutdownLeases.get(lease.ownerId);
984
+ if (!state || state.lease.id !== lease.id) return;
985
+ if (outcome === "commit" && state.proof?.confirmed) {
986
+ const pendingJobIds = this.getDeliveryState({ ownerId: lease.ownerId }).pendingJobIds;
987
+ this.acknowledgeDeliveries(pendingJobIds);
988
+ this.#purgeOwnerSubagentState(lease.ownerId);
989
+ }
990
+ this.#ownerSubagentShutdownLeases.delete(lease.ownerId);
991
+ if (outcome === "release") this.#ensureDeliveryLoop();
992
+ }
993
+
707
994
  #recordByJobId(jobId: string): SubagentRecord | undefined {
708
995
  for (const rec of this.#subagentRecords.values()) {
709
996
  if (rec.currentJobId === jobId) return rec;
@@ -758,6 +1045,9 @@ export class AsyncJobManager {
758
1045
  ): { ok: boolean; status?: SubagentLifecycle; jobId?: string; queued?: boolean; reason?: string } {
759
1046
  const rec = this.getSubagentRecord(subagentId, filter);
760
1047
  if (!rec) return { ok: false, reason: "not_found" };
1048
+ if (this.#isOwnerSubagentShutdownFenced(rec.ownerId)) {
1049
+ return { ok: false, status: rec.status, reason: "owner_shutdown_in_progress" };
1050
+ }
761
1051
  if (rec.status === "running") return { ok: false, status: "running", reason: "already_running" };
762
1052
  if (rec.status === "queued") {
763
1053
  if (message !== undefined && rec.queued) {
@@ -769,7 +1059,7 @@ export class AsyncJobManager {
769
1059
  return { ok: false, status: "queued", reason: "already_queued" };
770
1060
  }
771
1061
  if (!rec.resumable || !rec.sessionFile) return { ok: false, reason: "context_unavailable" };
772
- if (!this.#resumeRunner) return { ok: false, reason: "no_runner" };
1062
+ if (!this.#resolveResumeRunner(rec.subagentId)) return { ok: false, reason: "no_runner" };
773
1063
  if (this.getRunningJobs().length >= this.#maxRunningJobs) {
774
1064
  const seq = ++this.#resumeSeq;
775
1065
  rec.status = "queued";
@@ -790,11 +1080,15 @@ export class AsyncJobManager {
790
1080
  rec: SubagentRecord,
791
1081
  message?: string,
792
1082
  ): { ok: boolean; status?: SubagentLifecycle; jobId?: string; reason?: string } {
1083
+ if (this.#isOwnerSubagentShutdownFenced(rec.ownerId)) {
1084
+ return { ok: false, status: rec.status, reason: "owner_shutdown_in_progress" };
1085
+ }
793
1086
  const prevJobId = rec.currentJobId;
794
1087
  // Clear any retained progress from the previous run so a resumed subagent
795
1088
  // never renders the prior run's tool/output as live before it emits again.
796
1089
  this.#subagentProgress.delete(rec.subagentId);
797
- const newJobId = this.#resumeRunner?.(rec.subagentId, message, this.#resumeDescriptors.get(rec.subagentId));
1090
+ const runner = this.#resolveResumeRunner(rec.subagentId);
1091
+ const newJobId = runner?.(rec.subagentId, message, this.#resumeDescriptors.get(rec.subagentId));
798
1092
  if (!newJobId) return { ok: false, reason: "resume_failed" };
799
1093
  if (prevJobId && prevJobId !== newJobId) rec.historicalJobIds.push(prevJobId);
800
1094
  rec.currentJobId = newJobId;
@@ -803,16 +1097,36 @@ export class AsyncJobManager {
803
1097
  return { ok: true, status: rec.status, jobId: newJobId };
804
1098
  }
805
1099
 
806
- /** Drain queued resumes (FIFO by seq) while concurrency slots are available. */
1100
+ /** Drain queued resumes while preserving fenced owners and allowing foreign progress. */
807
1101
  #drainResumeQueue(): void {
808
1102
  if (this.#resumeQueue.length === 0) return;
809
1103
  this.#resumeQueue.sort((a, b) => a.seq - b.seq);
810
- while (this.#resumeQueue.length > 0 && this.getRunningJobs().length < this.#maxRunningJobs) {
811
- const entry = this.#resumeQueue.shift();
812
- if (!entry) return;
1104
+ let index = 0;
1105
+ while (index < this.#resumeQueue.length && this.getRunningJobs().length < this.#maxRunningJobs) {
1106
+ const entry = this.#resumeQueue[index];
813
1107
  const rec = this.#subagentRecords.get(entry.subagentId);
814
- if (rec?.status !== "queued") continue;
815
- this.#startResume(rec, entry.message);
1108
+ if (rec?.status !== "queued") {
1109
+ this.#resumeQueue.splice(index, 1);
1110
+ continue;
1111
+ }
1112
+ if (this.#isOwnerSubagentShutdownFenced(entry.ownerId)) {
1113
+ index += 1;
1114
+ continue;
1115
+ }
1116
+ try {
1117
+ const result = this.#startResume(rec, entry.message);
1118
+ if (result.reason === "owner_shutdown_in_progress") {
1119
+ index += 1;
1120
+ continue;
1121
+ }
1122
+ this.#resumeQueue.splice(index, 1);
1123
+ } catch (error) {
1124
+ if (error instanceof OwnerSubagentShutdownError) {
1125
+ index += 1;
1126
+ continue;
1127
+ }
1128
+ throw error;
1129
+ }
816
1130
  }
817
1131
  }
818
1132
 
@@ -854,6 +1168,7 @@ export class AsyncJobManager {
854
1168
  if (!ownerId || rec.ownerId === ownerId) {
855
1169
  this.#liveHandles.delete(sid);
856
1170
  this.#resumeDescriptors.delete(sid);
1171
+ this.#descriptorResumeRunners.delete(sid);
857
1172
  this.#subagentRecords.delete(sid);
858
1173
  this.#subagentProgress.delete(sid);
859
1174
  }
@@ -1011,36 +1326,10 @@ export class AsyncJobManager {
1011
1326
  };
1012
1327
  }
1013
1328
 
1014
- /**
1015
- * Run and clear every registered cleanup for the given filter. Idempotent
1016
- * and error-isolated: a throwing cleanup does not prevent siblings from
1017
- * running and never escalates to the caller.
1018
- */
1329
+ /** Run producer cleanups, then perform the legacy destructive subagent purge. */
1019
1330
  runOwnerCleanups(filter?: AsyncJobFilter): void {
1020
- const ownerId = filter?.ownerId;
1021
- const targets: Array<[string, Set<() => void>]> = [];
1022
- if (ownerId) {
1023
- const bag = this.#ownerCleanups.get(ownerId);
1024
- if (bag) targets.push([ownerId, bag]);
1025
- } else {
1026
- for (const entry of this.#ownerCleanups.entries()) targets.push(entry);
1027
- }
1028
- for (const [id, bag] of targets) {
1029
- const callbacks = Array.from(bag);
1030
- bag.clear();
1031
- this.#ownerCleanups.delete(id);
1032
- for (const cleanup of callbacks) {
1033
- try {
1034
- cleanup();
1035
- } catch (error) {
1036
- logger.warn("Async job owner cleanup failed", {
1037
- ownerId: id,
1038
- error: error instanceof Error ? error.message : String(error),
1039
- });
1040
- }
1041
- }
1042
- }
1043
- this.#purgeOwnerSubagentState(ownerId);
1331
+ this.runOwnerProducerCleanups(filter);
1332
+ this.#purgeOwnerSubagentState(filter?.ownerId);
1044
1333
  }
1045
1334
 
1046
1335
  getDeliveryState(filter?: AsyncJobFilter): AsyncJobDeliveryState {
@@ -1084,6 +1373,7 @@ export class AsyncJobManager {
1084
1373
  removed += 1;
1085
1374
  }
1086
1375
  }
1376
+ if (removed > 0) this.#ensureDeliveryLoop();
1087
1377
  return removed;
1088
1378
  }
1089
1379
 
@@ -1099,7 +1389,7 @@ export class AsyncJobManager {
1099
1389
  this.#deliveries.splice(
1100
1390
  0,
1101
1391
  this.#deliveries.length,
1102
- ...this.#deliveries.filter(delivery => !this.isDeliverySuppressed(delivery.jobId)),
1392
+ ...this.#deliveries.filter(delivery => !this.#isDeliveryAcknowledged(delivery.jobId)),
1103
1393
  );
1104
1394
  return before - this.#deliveries.length;
1105
1395
  }
@@ -1119,6 +1409,48 @@ export class AsyncJobManager {
1119
1409
  }
1120
1410
  }
1121
1411
 
1412
+ async waitForOwnerInFlightDeliveries(ownerId: string, options?: { timeoutMs?: number }): Promise<boolean> {
1413
+ const inFlight = this.#inFlightDeliveries
1414
+ .filter(delivery => delivery.ownerId === ownerId)
1415
+ .map(delivery => delivery.promise)
1416
+ .filter((promise): promise is Promise<void> => promise !== undefined);
1417
+ if (inFlight.length === 0) return true;
1418
+ const timeoutMs = Math.max(0, options?.timeoutMs ?? OWNER_SUBAGENT_SHUTDOWN_TIMEOUT_MS);
1419
+ let timedOut = false;
1420
+ await Promise.race([
1421
+ Promise.allSettled(inFlight),
1422
+ Bun.sleep(timeoutMs).then(() => {
1423
+ timedOut = true;
1424
+ }),
1425
+ ]);
1426
+ return !timedOut;
1427
+ }
1428
+ async cancelAndSettleOwnerJobs(ownerId: string, options?: { timeoutMs?: number }): Promise<boolean> {
1429
+ const jobs = this.getAllJobs({ ownerId });
1430
+ for (const job of jobs) this.cancel(job.id, { ownerId });
1431
+ const timeoutMs = Math.max(0, options?.timeoutMs ?? OWNER_SUBAGENT_SHUTDOWN_TIMEOUT_MS);
1432
+ let timedOut = false;
1433
+ await Promise.race([
1434
+ Promise.allSettled(jobs.map(job => job.promise)),
1435
+ Bun.sleep(timeoutMs).then(() => {
1436
+ timedOut = true;
1437
+ }),
1438
+ ]);
1439
+ const inFlight = this.#inFlightDeliveries
1440
+ .filter(delivery => delivery.ownerId === ownerId)
1441
+ .map(delivery => delivery.promise)
1442
+ .filter((promise): promise is Promise<void> => promise !== undefined);
1443
+ if (inFlight.length === 0) return !timedOut;
1444
+ let deliveryTimedOut = false;
1445
+ await Promise.race([
1446
+ Promise.allSettled(inFlight),
1447
+ Bun.sleep(timeoutMs).then(() => {
1448
+ deliveryTimedOut = true;
1449
+ }),
1450
+ ]);
1451
+ return !timedOut && !deliveryTimedOut;
1452
+ }
1453
+
1122
1454
  getLastDisposeDiagnostics(): AsyncJobDisposeDiagnostics {
1123
1455
  return { ...this.#lastDisposeDiagnostics, stuckJobIds: [...this.#lastDisposeDiagnostics.stuckJobIds] };
1124
1456
  }
@@ -1229,7 +1561,9 @@ export class AsyncJobManager {
1229
1561
  this.#liveHandles.clear();
1230
1562
  this.#subagentProgress.clear();
1231
1563
  this.#resumeDescriptors.clear();
1564
+ this.#descriptorResumeRunners.clear();
1232
1565
  this.#resumeQueue.length = 0;
1566
+ this.#ownerSubagentShutdownLeases.clear();
1233
1567
  this.#notifyChange();
1234
1568
  this.#changeListeners.clear();
1235
1569
  return drained && waitResult.completed;
@@ -1315,12 +1649,22 @@ export class AsyncJobManager {
1315
1649
  );
1316
1650
  }
1317
1651
 
1652
+ #isDeliveryFenced(delivery: AsyncJobDelivery): boolean {
1653
+ return Boolean(delivery.ownerId && this.#isOwnerSubagentShutdownFenced(delivery.ownerId));
1654
+ }
1655
+
1656
+ #hasDeliverable(): boolean {
1657
+ return this.#deliveries.some(
1658
+ delivery => !this.isDeliverySuppressed(delivery.jobId) && !this.#isDeliveryFenced(delivery),
1659
+ );
1660
+ }
1661
+
1318
1662
  async #deliverNextFiltered(filter: AsyncJobFilter, deadline: number): Promise<boolean> {
1319
1663
  while (true) {
1320
1664
  let selected: AsyncJobDelivery | undefined;
1321
1665
  for (const delivery of this.#deliveries) {
1322
1666
  if (delivery.ownerId !== filter.ownerId) continue;
1323
- if (this.isDeliverySuppressed(delivery.jobId)) continue;
1667
+ if (this.isDeliverySuppressed(delivery.jobId) || this.#isDeliveryFenced(delivery)) continue;
1324
1668
  if (!selected || delivery.nextAttemptAt < selected.nextAttemptAt) {
1325
1669
  selected = delivery;
1326
1670
  }
@@ -1328,7 +1672,7 @@ export class AsyncJobManager {
1328
1672
 
1329
1673
  if (!selected) {
1330
1674
  const inFlight = this.#filterInFlightDeliveries(filter);
1331
- if (inFlight.length === 0) return true;
1675
+ if (inFlight.length === 0) return this.#filterDeliveries(filter).length === 0;
1332
1676
  return this.#waitForDeliveryPromise(inFlight[0]?.promise, deadline);
1333
1677
  }
1334
1678
 
@@ -1348,13 +1692,17 @@ export class AsyncJobManager {
1348
1692
  }
1349
1693
  }
1350
1694
 
1695
+ #isDeliveryAcknowledged(jobId: string): boolean {
1696
+ return this.#suppressedDeliveries.has(jobId);
1697
+ }
1698
+
1351
1699
  isDeliverySuppressed(jobId: string): boolean {
1352
- return this.#suppressedDeliveries.has(jobId) || this.#watchedJobs.has(jobId);
1700
+ return this.#isDeliveryAcknowledged(jobId) || this.#watchedJobs.has(jobId);
1353
1701
  }
1354
1702
 
1355
1703
  #enqueueDelivery(jobId: string, text: string): void {
1356
1704
  // Skip delivery if already acknowledged
1357
- if (this.isDeliverySuppressed(jobId)) {
1705
+ if (this.#isDeliveryAcknowledged(jobId)) {
1358
1706
  return;
1359
1707
  }
1360
1708
  const deliveryText = this.#boundedDeliveryText(text);
@@ -1399,7 +1747,7 @@ export class AsyncJobManager {
1399
1747
  })
1400
1748
  .finally(() => {
1401
1749
  this.#deliveryLoop = undefined;
1402
- if (!this.#disposed && this.#deliveries.length > 0) {
1750
+ if (!this.#disposed && this.#hasDeliverable()) {
1403
1751
  this.#ensureDeliveryLoop();
1404
1752
  }
1405
1753
  });
@@ -1407,24 +1755,19 @@ export class AsyncJobManager {
1407
1755
 
1408
1756
  async #runDeliveryLoop(): Promise<void> {
1409
1757
  while (this.#deliveries.length > 0) {
1410
- const delivery = this.#deliveries[0];
1411
- if (this.isDeliverySuppressed(delivery.jobId)) {
1412
- this.#deliveries.shift();
1413
- continue;
1414
- }
1758
+ const delivery = this.#deliveries.find(
1759
+ candidate => !this.isDeliverySuppressed(candidate.jobId) && !this.#isDeliveryFenced(candidate),
1760
+ );
1761
+ if (!delivery) return;
1415
1762
  const waitMs = delivery.nextAttemptAt - Date.now();
1416
1763
  if (waitMs > 0) {
1417
1764
  await Bun.sleep(waitMs);
1418
1765
  }
1419
- if (this.#deliveries[0] !== delivery) {
1420
- continue;
1421
- }
1422
- if (this.isDeliverySuppressed(delivery.jobId)) {
1423
- this.#deliveries.shift();
1424
- continue;
1425
- }
1766
+ const index = this.#deliveries.indexOf(delivery);
1767
+ if (index === -1) continue;
1768
+ if (this.isDeliverySuppressed(delivery.jobId) || this.#isDeliveryFenced(delivery)) continue;
1426
1769
 
1427
- this.#deliveries.shift();
1770
+ this.#deliveries.splice(index, 1);
1428
1771
  await this.#deliverDelivery(delivery);
1429
1772
  }
1430
1773
  }
@@ -1446,7 +1789,7 @@ export class AsyncJobManager {
1446
1789
  });
1447
1790
  } else {
1448
1791
  delivery.nextAttemptAt = Date.now() + this.#getRetryDelay(delivery.attempt);
1449
- if (!this.isDeliverySuppressed(delivery.jobId)) {
1792
+ if (!this.#isDeliveryAcknowledged(delivery.jobId)) {
1450
1793
  this.#deliveries.push(delivery);
1451
1794
  }
1452
1795
  logger.warn("Async job completion delivery failed", {
@@ -1459,7 +1802,7 @@ export class AsyncJobManager {
1459
1802
  } finally {
1460
1803
  const index = this.#inFlightDeliveries.indexOf(delivery);
1461
1804
  if (index !== -1) this.#inFlightDeliveries.splice(index, 1);
1462
- if (!this.#disposed && this.#deliveries.length > 0) this.#ensureDeliveryLoop();
1805
+ if (!this.#disposed && this.#hasDeliverable()) this.#ensureDeliveryLoop();
1463
1806
  }
1464
1807
  })();
1465
1808
  delivery.promise = promise;