@gajae-code/coding-agent 0.12.5 → 0.12.7

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 (91) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +4 -1
  3. package/dist/types/async/job-manager.d.ts +36 -1
  4. package/dist/types/extensibility/extensions/runner.d.ts +9 -7
  5. package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
  6. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  7. package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
  8. package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
  9. package/dist/types/gjc-runtime/team-store.d.ts +0 -4
  10. package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
  11. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  12. package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
  13. package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
  14. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
  15. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +21 -4
  16. package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
  17. package/dist/types/modes/components/plugin-settings.d.ts +2 -0
  18. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  19. package/dist/types/sdk/broker/broker.d.ts +18 -0
  20. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
  21. package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
  22. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -3
  23. package/dist/types/sdk/bus/index.d.ts +4 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +2 -2
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
  26. package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
  27. package/dist/types/session/agent-session.d.ts +5 -4
  28. package/dist/types/session/attempt-record-store.d.ts +19 -0
  29. package/dist/types/session/session-manager.d.ts +1 -0
  30. package/dist/types/session/session-storage.d.ts +8 -0
  31. package/dist/types/task/receipt.d.ts +1 -0
  32. package/dist/types/task/types.d.ts +43 -0
  33. package/dist/types/tools/subagent.d.ts +15 -0
  34. package/dist/types/tools/todo-write.d.ts +3 -0
  35. package/package.json +8 -8
  36. package/scripts/capture-sticky-viewport-showcase.ts +131 -5
  37. package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
  38. package/scripts/verify-sticky-viewport-showcase.ts +620 -93
  39. package/src/async/job-manager.ts +430 -46
  40. package/src/config/model-profiles.ts +8 -0
  41. package/src/config/settings-schema.ts +17 -3
  42. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  43. package/src/extensibility/extensions/runner.ts +80 -6
  44. package/src/extensibility/extensions/wrapper.ts +22 -15
  45. package/src/extensibility/runtime-skill-discovery.ts +4 -6
  46. package/src/gjc-runtime/launch-tmux.ts +309 -293
  47. package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
  48. package/src/gjc-runtime/psmux-detect.ts +52 -6
  49. package/src/gjc-runtime/team-launch.ts +50 -2
  50. package/src/gjc-runtime/team-runtime.ts +716 -251
  51. package/src/gjc-runtime/team-store.ts +25 -7
  52. package/src/gjc-runtime/team-workers.ts +161 -1
  53. package/src/gjc-runtime/tmux-common.ts +16 -4
  54. package/src/gjc-runtime/tmux-gc.ts +37 -5
  55. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
  56. package/src/gjc-runtime/tmux-provider-context.ts +451 -0
  57. package/src/gjc-runtime/tmux-sessions.ts +409 -156
  58. package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
  59. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
  60. package/src/gjc-runtime/ultragoal-runtime.ts +360 -156
  61. package/src/internal-urls/docs-index.generated.ts +4 -4
  62. package/src/internal-urls/local-protocol.ts +39 -18
  63. package/src/modes/acp/acp-agent.ts +27 -13
  64. package/src/modes/components/gjc-bundle-settings.ts +2 -0
  65. package/src/modes/components/plugin-settings.ts +5 -0
  66. package/src/modes/components/settings-selector.ts +4 -0
  67. package/src/modes/controllers/selector-controller.ts +1 -0
  68. package/src/prompts/tools/monitor.md +4 -4
  69. package/src/sdk/broker/broker.ts +93 -10
  70. package/src/sdk/broker/lifecycle-ledger.ts +227 -11
  71. package/src/sdk/broker/lifecycle.ts +691 -92
  72. package/src/sdk/bus/chat-daemon-control.ts +30 -6
  73. package/src/sdk/bus/index.ts +23 -6
  74. package/src/sdk/bus/telegram-daemon-contract.ts +2 -2
  75. package/src/sdk/bus/telegram-daemon.ts +283 -44
  76. package/src/sdk/bus/topic-registry.ts +133 -10
  77. package/src/sdk/session.ts +7 -6
  78. package/src/session/agent-session.ts +752 -339
  79. package/src/session/attempt-record-store.ts +106 -0
  80. package/src/session/internal/managed-session-scope.ts +252 -67
  81. package/src/session/internal/managed-session-storage.ts +1 -0
  82. package/src/session/session-manager.ts +7 -0
  83. package/src/session/session-storage.ts +146 -24
  84. package/src/task/index.ts +230 -19
  85. package/src/task/provider-retry-status.ts +4 -1
  86. package/src/task/receipt.ts +2 -0
  87. package/src/task/types.ts +10 -0
  88. package/src/tools/monitor.ts +7 -1
  89. package/src/tools/output-meta.ts +10 -2
  90. package/src/tools/subagent.ts +97 -75
  91. package/src/tools/todo-write.ts +47 -0
@@ -16,6 +16,7 @@ const MAX_DEAD_LETTERED_DELIVERIES = 50;
16
16
 
17
17
  export interface AsyncJob {
18
18
  id: string;
19
+ readonly generation: string;
19
20
  type: "bash" | "task";
20
21
  status: "running" | "completed" | "failed" | "cancelled" | "paused";
21
22
  startTime: number;
@@ -61,6 +62,8 @@ export interface AsyncJobMetadata {
61
62
  agentSource: AgentSource;
62
63
  description?: string;
63
64
  assignment?: string;
65
+ duplicateIdentity?: string;
66
+ duplicateDisposition?: "warned" | "superseded";
64
67
  };
65
68
  /** True when this bash job was started by the `monitor` tool (vs plain async bash). */
66
69
  monitor?: boolean;
@@ -166,6 +169,11 @@ export interface SubagentRecord {
166
169
  modelFellBack?: boolean;
167
170
  /** True when the effective subagent provider is in fast mode. */
168
171
  fastMode?: boolean;
172
+ duplicateIdentity?: string;
173
+ duplicateDisposition?: "warned" | "superseded";
174
+ terminalGeneration?: string;
175
+ /** Generation of currentJobId, preventing stale ID reuse from mutating this record. */
176
+ currentJobGeneration?: string;
169
177
  }
170
178
 
171
179
  /** Lightweight, manager-owned resume payload. The async layer treats `data` as opaque. */
@@ -225,6 +233,8 @@ export interface AsyncJobDisposeDiagnostics {
225
233
 
226
234
  interface AsyncJobDelivery {
227
235
  jobId: string;
236
+ generation: string;
237
+ job: AsyncJob;
228
238
  text: string;
229
239
  originalBytes?: number;
230
240
  truncated?: boolean;
@@ -237,6 +247,7 @@ interface AsyncJobDelivery {
237
247
 
238
248
  interface DeadLetteredDelivery {
239
249
  jobId: string;
250
+ generation: string;
240
251
  attempt: number;
241
252
  lastError?: string;
242
253
  }
@@ -289,6 +300,51 @@ export interface AsyncJobFilter {
289
300
  ownerId?: string;
290
301
  }
291
302
 
303
+ export type AsyncJobWaitCondition = "all_terminal" | "any_terminal";
304
+ export type AsyncJobWaitOutcome = "completed" | "timed_out_wait" | "interrupted";
305
+
306
+ export interface AsyncJobWaitTarget {
307
+ targetId: string;
308
+ jobId: string | null;
309
+ subagentId?: string;
310
+ generation: string;
311
+ ownerId?: string;
312
+ initialStatus: AsyncJob["status"] | "queued" | "not_found";
313
+ }
314
+
315
+ export interface AsyncJobWaitResult {
316
+ outcome: AsyncJobWaitOutcome;
317
+ condition: AsyncJobWaitCondition;
318
+ terminalJobIds: string[];
319
+ pendingJobIds: string[];
320
+ }
321
+
322
+ export interface AsyncJobWaitHandle {
323
+ readonly token: string;
324
+ readonly result: Promise<AsyncJobWaitResult>;
325
+ acknowledge(targetIds?: readonly string[]): { acknowledged: boolean; jobIds: string[] };
326
+ close(): void;
327
+ }
328
+
329
+ interface TerminalEvent {
330
+ generation: string;
331
+ jobId: string | null;
332
+ subagentId?: string;
333
+ ownerId?: string;
334
+ status: "completed" | "failed" | "cancelled";
335
+ createdAt: number;
336
+ }
337
+
338
+ interface TerminalWaitState {
339
+ token: string;
340
+ targets: AsyncJobWaitTarget[];
341
+ condition: AsyncJobWaitCondition;
342
+ resolve: (result: AsyncJobWaitResult) => void;
343
+ settled: boolean;
344
+ acknowledged: boolean;
345
+ terminalGenerations: Set<string>;
346
+ }
347
+
292
348
  function sliceTextFromUtf8ByteOffset(text: string, offsetBytes: number): string {
293
349
  if (offsetBytes <= 0) return text;
294
350
  let consumedBytes = 0;
@@ -387,6 +443,7 @@ export class AsyncJobManager {
387
443
  readonly #deliveries: AsyncJobDelivery[] = [];
388
444
  readonly #inFlightDeliveries: AsyncJobDelivery[] = [];
389
445
  readonly #suppressedDeliveries = new Set<string>();
446
+ readonly #deliveryAckOwners = new Map<string, string>();
390
447
  readonly #watchedJobs = new Set<string>();
391
448
  readonly #evictionTimers = new Map<string, NodeJS.Timeout>();
392
449
  readonly #outputState = new Map<string, AsyncJobOutputState>();
@@ -401,6 +458,12 @@ export class AsyncJobManager {
401
458
  #deliveryLoop: Promise<void> | undefined;
402
459
  #disposed = false;
403
460
  readonly #subagentRecords = new Map<string, SubagentRecord>();
461
+ readonly #terminalEvents = new Map<string, TerminalEvent>();
462
+ readonly #waitGenerationAliases = new Map<string, string>();
463
+ readonly #terminalWaits = new Map<string, TerminalWaitState>();
464
+ #waitSeq = 0;
465
+ readonly #publishedTerminalGenerations = new Set<string>();
466
+ #jobGenerationSeq = 0;
404
467
  readonly #liveHandles = new Map<string, SubagentLiveHandle>();
405
468
  readonly #subagentProgress = new Map<string, AgentProgress>();
406
469
  readonly #resumeQueue: ResumeQueueEntry[] = [];
@@ -427,6 +490,272 @@ export class AsyncJobManager {
427
490
  */
428
491
  readonly #changeListeners = new Set<() => void>();
429
492
 
493
+ #pruneTerminalEvents(): void {
494
+ const cutoff = Date.now() - Math.max(this.#retentionMs, 300_000);
495
+ for (const [generation, event] of this.#terminalEvents) {
496
+ if (event.createdAt >= cutoff) continue;
497
+ this.#terminalEvents.delete(generation);
498
+ this.#publishedTerminalGenerations.delete(generation);
499
+ this.#deliveryAckOwners.delete(generation);
500
+ }
501
+ }
502
+
503
+ #eventForTarget(target: AsyncJobWaitTarget): TerminalEvent | undefined {
504
+ this.#pruneTerminalEvents();
505
+ const aliasedGeneration = this.#waitGenerationAliases.get(target.generation);
506
+ if (aliasedGeneration) return this.#terminalEvents.get(aliasedGeneration);
507
+ const job = target.jobId ? this.#jobs.get(target.jobId) : undefined;
508
+ if (job) {
509
+ if (job.generation !== target.generation) return undefined;
510
+ if (job.status === "completed" || job.status === "failed" || job.status === "cancelled") {
511
+ return {
512
+ generation: job.generation,
513
+ jobId: job.id,
514
+ subagentId: target.subagentId,
515
+ ownerId: job.ownerId,
516
+ status: job.status,
517
+ createdAt: job.endTime ?? Date.now(),
518
+ };
519
+ }
520
+ return undefined;
521
+ }
522
+ return this.#terminalEvents.get(target.generation);
523
+ }
524
+
525
+ #resultForWait(state: TerminalWaitState, outcome: AsyncJobWaitOutcome): AsyncJobWaitResult {
526
+ const terminalJobIds: string[] = [];
527
+ const pendingJobIds: string[] = [];
528
+ for (const target of state.targets) {
529
+ if (this.#eventForTarget(target)) terminalJobIds.push(target.targetId);
530
+ else pendingJobIds.push(target.targetId);
531
+ }
532
+ return { outcome, condition: state.condition, terminalJobIds, pendingJobIds };
533
+ }
534
+
535
+ #maybeResolveWait(state: TerminalWaitState): void {
536
+ if (state.settled) return;
537
+ const terminalCount = state.targets.filter(target => this.#eventForTarget(target)).length;
538
+ if (state.condition === "all_terminal" ? terminalCount !== state.targets.length : terminalCount === 0) return;
539
+ state.settled = true;
540
+ this.#terminalWaits.delete(state.token);
541
+ state.resolve(this.#resultForWait(state, "completed"));
542
+ }
543
+
544
+ #publishQueuedTerminal(subagentId: string, generation: string, status: "completed" | "failed" | "cancelled"): void {
545
+ if (this.#publishedTerminalGenerations.has(generation)) return;
546
+ this.#publishedTerminalGenerations.add(generation);
547
+ const record = this.#subagentRecords.get(subagentId);
548
+ if (record) {
549
+ record.currentJobId = null;
550
+ record.terminalGeneration = generation;
551
+ record.status = status;
552
+ record.queued = undefined;
553
+ }
554
+ this.#terminalEvents.set(generation, {
555
+ generation,
556
+ jobId: null,
557
+ subagentId,
558
+ ownerId: record?.ownerId,
559
+ status,
560
+ createdAt: Date.now(),
561
+ });
562
+ for (const state of this.#terminalWaits.values()) {
563
+ if (
564
+ state.targets.some(
565
+ target =>
566
+ target.generation === generation || this.#waitGenerationAliases.get(target.generation) === generation,
567
+ )
568
+ )
569
+ this.#maybeResolveWait(state);
570
+ }
571
+ }
572
+
573
+ #publishTerminal(job: AsyncJob): void {
574
+ if (job.status !== "completed" && job.status !== "failed" && job.status !== "cancelled") return;
575
+ if (this.#publishedTerminalGenerations.has(job.generation)) return;
576
+ this.#publishedTerminalGenerations.add(job.generation);
577
+ const record = Array.from(this.#subagentRecords.values()).find(
578
+ item => item.currentJobId === job.id && item.currentJobGeneration === job.generation,
579
+ );
580
+ if (record) record.terminalGeneration = job.generation;
581
+ this.#terminalEvents.set(job.generation, {
582
+ generation: job.generation,
583
+ jobId: job.id,
584
+ subagentId: record?.subagentId,
585
+ ownerId: job.ownerId,
586
+ status: job.status,
587
+ createdAt: Date.now(),
588
+ });
589
+ for (const state of this.#terminalWaits.values()) {
590
+ if (
591
+ state.targets.some(
592
+ target =>
593
+ target.generation === job.generation ||
594
+ target.jobId === job.id ||
595
+ this.#waitGenerationAliases.get(target.generation) === job.generation,
596
+ )
597
+ )
598
+ this.#maybeResolveWait(state);
599
+ }
600
+ }
601
+
602
+ resolveSubagentWaitTarget(id: string, filter?: AsyncJobFilter): AsyncJobWaitTarget | undefined {
603
+ const targetId = id.trim();
604
+ if (!targetId) return undefined;
605
+ this.#pruneTerminalEvents();
606
+ const record = this.getSubagentRecord(targetId, filter);
607
+ if (record) {
608
+ if (filter?.ownerId && record.ownerId !== filter.ownerId) return undefined;
609
+ if (record.status === "queued" && record.queued?.seq !== undefined) {
610
+ return {
611
+ targetId,
612
+ jobId: null,
613
+ subagentId: record.subagentId,
614
+ generation: `queued:${record.subagentId}:${record.queued.seq}`,
615
+ ownerId: record.ownerId,
616
+ initialStatus: "queued",
617
+ };
618
+ }
619
+ if (record.terminalGeneration && this.#terminalEvents.has(record.terminalGeneration)) {
620
+ const generation = record.terminalGeneration;
621
+ const event = this.#terminalEvents.get(generation);
622
+ const current = record.currentJobId ? this.#jobs.get(record.currentJobId) : undefined;
623
+ if (!current || current.generation !== record.currentJobGeneration || current.generation !== generation) {
624
+ return {
625
+ targetId,
626
+ jobId: event?.jobId ?? null,
627
+ subagentId: record.subagentId,
628
+ generation,
629
+ ownerId: record.ownerId,
630
+ initialStatus: record.status,
631
+ };
632
+ }
633
+ }
634
+ if (record.currentJobId) {
635
+ const job = this.#jobs.get(record.currentJobId);
636
+ if (job && record.currentJobGeneration === job.generation)
637
+ return {
638
+ targetId,
639
+ jobId: record.currentJobId,
640
+ subagentId: record.subagentId,
641
+ generation: job.generation,
642
+ ownerId: record.ownerId,
643
+ initialStatus: job.status,
644
+ };
645
+ }
646
+ if (record.terminalGeneration && this.#terminalEvents.has(record.terminalGeneration)) {
647
+ const generation = record.terminalGeneration;
648
+ return {
649
+ targetId,
650
+ jobId: generation.startsWith("queued:") ? null : generation,
651
+ subagentId: record.subagentId,
652
+ generation,
653
+ ownerId: record.ownerId,
654
+ initialStatus: record.status,
655
+ };
656
+ }
657
+ return undefined;
658
+ }
659
+ const job = this.#jobs.get(targetId);
660
+ if (job && (!filter?.ownerId || job.ownerId === filter.ownerId))
661
+ return {
662
+ targetId,
663
+ jobId: job.id,
664
+ subagentId: job.metadata?.subagent?.id,
665
+ generation: job.generation,
666
+ ownerId: job.ownerId,
667
+ initialStatus: job.status,
668
+ };
669
+ const metadataJobs = Array.from(this.#jobs.values()).filter(
670
+ candidate =>
671
+ candidate.metadata?.subagent?.id === targetId && (!filter?.ownerId || candidate.ownerId === filter.ownerId),
672
+ );
673
+ const metadataJob = metadataJobs.sort((a, b) => b.startTime - a.startTime)[0];
674
+ if (metadataJob)
675
+ return {
676
+ targetId,
677
+ jobId: metadataJob.id,
678
+ subagentId: metadataJob.metadata?.subagent?.id,
679
+ generation: metadataJob.generation,
680
+ ownerId: metadataJob.ownerId,
681
+ initialStatus: metadataJob.status,
682
+ };
683
+ const event = this.#terminalEvents.get(targetId);
684
+ if (event && (!filter?.ownerId || event.ownerId === filter.ownerId))
685
+ return {
686
+ targetId,
687
+ jobId: event.jobId,
688
+ subagentId: event.subagentId,
689
+ generation: event.generation,
690
+ ownerId: event.ownerId,
691
+ initialStatus: event.status,
692
+ };
693
+ return undefined;
694
+ }
695
+
696
+ subscribeTerminalWait(
697
+ targets: readonly AsyncJobWaitTarget[],
698
+ condition: AsyncJobWaitCondition = "all_terminal",
699
+ ): AsyncJobWaitHandle {
700
+ const deduped: AsyncJobWaitTarget[] = [];
701
+ const seen = new Set<string>();
702
+ for (const target of targets) {
703
+ if (seen.has(target.generation)) continue;
704
+ seen.add(target.generation);
705
+ deduped.push(target);
706
+ }
707
+ let resolve!: (result: AsyncJobWaitResult) => void;
708
+ const result = new Promise<AsyncJobWaitResult>(resolver => {
709
+ resolve = resolver;
710
+ });
711
+ const state: TerminalWaitState = {
712
+ token: `wait_${++this.#waitSeq}`,
713
+ targets: deduped,
714
+ condition,
715
+ resolve,
716
+ settled: false,
717
+ acknowledged: false,
718
+ terminalGenerations: new Set(),
719
+ };
720
+ const handle: AsyncJobWaitHandle = {
721
+ token: state.token,
722
+ result,
723
+ acknowledge: (targetIds?: readonly string[]) => {
724
+ if (state.acknowledged) return { acknowledged: false, jobIds: [] };
725
+ state.acknowledged = true;
726
+ const allowed = targetIds ? new Set(targetIds) : undefined;
727
+ const ids: string[] = [];
728
+ for (const target of deduped) {
729
+ if (allowed && !allowed.has(target.targetId)) continue;
730
+ const event = this.#eventForTarget(target);
731
+ if (!event) continue;
732
+ ids.push(event.jobId ?? event.generation);
733
+ const owner = this.#deliveryAckOwners.get(event.generation);
734
+ if (owner && owner !== state.token) continue;
735
+ this.#deliveryAckOwners.set(event.generation, state.token);
736
+ this.#suppressedDeliveries.add(event.generation);
737
+ }
738
+ this.#deliveries.splice(
739
+ 0,
740
+ this.#deliveries.length,
741
+ ...this.#deliveries.filter(
742
+ delivery => !this.#isDeliveryAcknowledged(delivery.jobId, delivery.generation),
743
+ ),
744
+ );
745
+ return { acknowledged: ids.length > 0, jobIds: ids };
746
+ },
747
+ close: () => {
748
+ if (state.settled) return;
749
+ state.settled = true;
750
+ this.#terminalWaits.delete(state.token);
751
+ resolve(this.#resultForWait(state, "interrupted"));
752
+ },
753
+ };
754
+ this.#terminalWaits.set(state.token, state);
755
+ this.#maybeResolveWait(state);
756
+ return handle;
757
+ }
758
+
430
759
  #filterJobs(jobs: Iterable<AsyncJob>, filter?: AsyncJobFilter): AsyncJob[] {
431
760
  const ownerId = filter?.ownerId;
432
761
  if (!ownerId) return Array.from(jobs);
@@ -491,12 +820,12 @@ export class AsyncJobManager {
491
820
 
492
821
  this.#expireMonitorTombstones();
493
822
  const id = this.#resolveJobId(options?.id);
494
- this.#suppressedDeliveries.delete(id);
495
823
  const abortController = new AbortController();
496
824
  const startTime = Date.now();
497
825
 
498
826
  const job: AsyncJob = {
499
827
  id,
828
+ generation: `job:${++this.#jobGenerationSeq}`,
500
829
  type,
501
830
  status: "running",
502
831
  startTime,
@@ -527,7 +856,8 @@ export class AsyncJobManager {
527
856
 
528
857
  if (job.status === "cancelled") {
529
858
  job.resultText = outcome.kind === "paused" ? outcome.note : outcome.text;
530
- this.#markRecordTerminal(id, "cancelled");
859
+ this.#markRecordTerminal(id, "cancelled", job.generation);
860
+ this.#publishTerminal(job);
531
861
  this.#runLifecycle(id, "terminal", job);
532
862
  this.#scheduleEviction(id);
533
863
  this.#drainResumeQueue();
@@ -540,7 +870,7 @@ export class AsyncJobManager {
540
870
  job.status = "paused";
541
871
  this.#freezeEndTime(job);
542
872
  if (outcome.note) job.resultText = outcome.note;
543
- this.#markRecordPaused(id);
873
+ this.#markRecordPaused(id, job.generation);
544
874
  this.#notifyChange();
545
875
  this.#drainResumeQueue();
546
876
  return;
@@ -550,7 +880,8 @@ export class AsyncJobManager {
550
880
  job.setupFailureSummary = outcome.setupFailureSummary;
551
881
  this.#freezeEndTime(job);
552
882
  job.errorText = outcome.text;
553
- this.#markRecordTerminal(id, "failed");
883
+ this.#markRecordTerminal(id, "failed", job.generation);
884
+ this.#publishTerminal(job);
554
885
  this.#enqueueDelivery(id, outcome.text);
555
886
  this.#runLifecycle(id, "terminal", job);
556
887
  this.#scheduleEviction(id);
@@ -561,7 +892,8 @@ export class AsyncJobManager {
561
892
  job.status = "completed";
562
893
  this.#freezeEndTime(job);
563
894
  job.resultText = outcome.text;
564
- this.#markRecordTerminal(id, "completed");
895
+ this.#markRecordTerminal(id, "completed", job.generation);
896
+ this.#publishTerminal(job);
565
897
  this.#enqueueDelivery(id, outcome.text);
566
898
  this.#runLifecycle(id, "terminal", job);
567
899
  this.#scheduleEviction(id);
@@ -569,7 +901,8 @@ export class AsyncJobManager {
569
901
  } catch (error) {
570
902
  if (job.status === "cancelled") {
571
903
  job.errorText = error instanceof Error ? error.message : String(error);
572
- this.#markRecordTerminal(id, "cancelled");
904
+ this.#markRecordTerminal(id, "cancelled", job.generation);
905
+ this.#publishTerminal(job);
573
906
  this.#runLifecycle(id, "terminal", job);
574
907
  this.#scheduleEviction(id);
575
908
  this.#drainResumeQueue();
@@ -579,7 +912,8 @@ export class AsyncJobManager {
579
912
  job.status = "failed";
580
913
  this.#freezeEndTime(job);
581
914
  job.errorText = errorText;
582
- this.#markRecordTerminal(id, "failed");
915
+ this.#markRecordTerminal(id, "failed", job.generation);
916
+ this.#publishTerminal(job);
583
917
  this.#enqueueDelivery(id, errorText);
584
918
  this.#runLifecycle(id, "terminal", job);
585
919
  this.#scheduleEviction(id);
@@ -605,7 +939,9 @@ export class AsyncJobManager {
605
939
  // Paused jobs have no running promise to abort; transition directly.
606
940
  // The session file is kept, so the record stays resumable by id.
607
941
  job.status = "cancelled";
608
- this.#markRecordTerminal(id, "cancelled");
942
+ this.#markRecordTerminal(id, "cancelled", job.generation);
943
+ this.#freezeEndTime(job);
944
+ this.#publishTerminal(job);
609
945
  this.#runLifecycle(id, "cancel");
610
946
  this.#scheduleEviction(id);
611
947
  this.#drainResumeQueue();
@@ -614,7 +950,8 @@ export class AsyncJobManager {
614
950
  if (job.status !== "running") return false;
615
951
  job.status = "cancelled";
616
952
  this.#freezeEndTime(job);
617
- this.#markRecordTerminal(id, "cancelled");
953
+ this.#markRecordTerminal(id, "cancelled", job.generation);
954
+ this.#publishTerminal(job);
618
955
  this.#runLifecycle(id, "cancel");
619
956
  job.abortController.abort();
620
957
  this.#notifyChange();
@@ -700,6 +1037,8 @@ export class AsyncJobManager {
700
1037
 
701
1038
  /** Register or replace the canonical record for a subagent. */
702
1039
  registerSubagentRecord(record: SubagentRecord): void {
1040
+ const currentJob = record.currentJobId ? this.#jobs.get(record.currentJobId) : undefined;
1041
+ if (currentJob && record.currentJobGeneration === undefined) record.currentJobGeneration = currentJob.generation;
703
1042
  this.#subagentRecords.set(record.subagentId, record);
704
1043
  this.#notifyChange();
705
1044
  }
@@ -1061,15 +1400,17 @@ export class AsyncJobManager {
1061
1400
  if (outcome === "release") this.#ensureDeliveryLoop();
1062
1401
  }
1063
1402
 
1064
- #recordByJobId(jobId: string): SubagentRecord | undefined {
1403
+ #recordByJobId(jobId: string, expectedGeneration?: string): SubagentRecord | undefined {
1065
1404
  for (const rec of this.#subagentRecords.values()) {
1066
- if (rec.currentJobId === jobId) return rec;
1405
+ if (rec.currentJobId !== jobId) continue;
1406
+ if (expectedGeneration !== undefined && rec.currentJobGeneration !== expectedGeneration) continue;
1407
+ return rec;
1067
1408
  }
1068
1409
  return undefined;
1069
1410
  }
1070
1411
 
1071
- #markRecordPaused(jobId: string): void {
1072
- const rec = this.#recordByJobId(jobId);
1412
+ #markRecordPaused(jobId: string, generation?: string): void {
1413
+ const rec = this.#recordByJobId(jobId, generation);
1073
1414
  if (rec) {
1074
1415
  rec.status = "paused";
1075
1416
  this.#liveHandles.delete(rec.subagentId);
@@ -1077,16 +1418,16 @@ export class AsyncJobManager {
1077
1418
  }
1078
1419
  }
1079
1420
 
1080
- #purgeTerminalSubagentStateForJob(jobId: string): void {
1081
- const rec = this.#recordByJobId(jobId);
1421
+ #purgeTerminalSubagentStateForJob(jobId: string, generation?: string): void {
1422
+ const rec = this.#recordByJobId(jobId, generation);
1082
1423
  if (!rec) return;
1083
1424
  if (rec.status === "paused" || rec.status === "queued") return;
1084
1425
  this.#liveHandles.delete(rec.subagentId);
1085
1426
  this.#subagentProgress.delete(rec.subagentId);
1086
1427
  }
1087
1428
 
1088
- #markRecordTerminal(jobId: string, status: "completed" | "failed" | "cancelled"): void {
1089
- const rec = this.#recordByJobId(jobId);
1429
+ #markRecordTerminal(jobId: string, status: "completed" | "failed" | "cancelled", generation?: string): void {
1430
+ const rec = this.#recordByJobId(jobId, generation);
1090
1431
  if (!rec) return;
1091
1432
  rec.status = status;
1092
1433
  this.#liveHandles.delete(rec.subagentId);
@@ -1163,6 +1504,7 @@ export class AsyncJobManager {
1163
1504
  return { ok: false, status: rec.status, reason: "owner_shutdown_in_progress" };
1164
1505
  }
1165
1506
  const prevJobId = rec.currentJobId;
1507
+ const queuedGeneration = rec.queued?.seq !== undefined ? `queued:${rec.subagentId}:${rec.queued.seq}` : undefined;
1166
1508
  // Clear any retained progress from the previous run so a resumed subagent
1167
1509
  // never renders the prior run's tool/output as live before it emits again.
1168
1510
  this.#subagentProgress.delete(rec.subagentId);
@@ -1170,9 +1512,13 @@ export class AsyncJobManager {
1170
1512
  const newJobId = runner?.(rec.subagentId, message, descriptor);
1171
1513
  if (!newJobId) return { ok: false, reason: "resume_failed" };
1172
1514
  if (prevJobId && prevJobId !== newJobId) rec.historicalJobIds.push(prevJobId);
1515
+ rec.terminalGeneration = undefined;
1173
1516
  rec.currentJobId = newJobId;
1517
+ rec.currentJobGeneration = this.#jobs.get(newJobId)?.generation;
1174
1518
  rec.status = this.#jobs.get(newJobId)?.status ?? "running";
1175
1519
  rec.queued = undefined;
1520
+ if (queuedGeneration)
1521
+ this.#waitGenerationAliases.set(queuedGeneration, this.#jobs.get(newJobId)?.generation ?? newJobId);
1176
1522
  this.#notifyChange();
1177
1523
  return { ok: true, status: rec.status, jobId: newJobId };
1178
1524
  }
@@ -1195,9 +1541,14 @@ export class AsyncJobManager {
1195
1541
  }
1196
1542
  try {
1197
1543
  const result = this.#startResume(rec, entry.message, this.#descriptorForRecord(rec));
1198
- if (result.reason === "owner_shutdown_in_progress") {
1199
- index += 1;
1200
- continue;
1544
+ if (!result.ok) {
1545
+ if (result.reason === "owner_shutdown_in_progress") {
1546
+ index += 1;
1547
+ continue;
1548
+ }
1549
+ const queuedSeq = rec.queued?.seq;
1550
+ if (queuedSeq !== undefined)
1551
+ this.#publishQueuedTerminal(rec.subagentId, `queued:${rec.subagentId}:${queuedSeq}`, "failed");
1201
1552
  }
1202
1553
  this.#resumeQueue.splice(index, 1);
1203
1554
  } catch (error) {
@@ -1205,7 +1556,10 @@ export class AsyncJobManager {
1205
1556
  index += 1;
1206
1557
  continue;
1207
1558
  }
1208
- throw error;
1559
+ const queuedSeq = rec.queued?.seq;
1560
+ if (queuedSeq !== undefined)
1561
+ this.#publishQueuedTerminal(rec.subagentId, `queued:${rec.subagentId}:${queuedSeq}`, "failed");
1562
+ this.#resumeQueue.splice(index, 1);
1209
1563
  }
1210
1564
  }
1211
1565
  }
@@ -1220,6 +1574,7 @@ export class AsyncJobManager {
1220
1574
  const job = currentJobId ? this.#jobs.get(currentJobId) : undefined;
1221
1575
  const shouldScheduleEviction = job?.status === "paused";
1222
1576
  if (shouldScheduleEviction) job.status = "cancelled";
1577
+ if (shouldScheduleEviction && job) this.#publishTerminal(job);
1223
1578
  rec.status = "cancelled";
1224
1579
  this.#liveHandles.delete(rec.subagentId);
1225
1580
  this.#subagentProgress.delete(rec.subagentId);
@@ -1230,8 +1585,11 @@ export class AsyncJobManager {
1230
1585
  }
1231
1586
  if (rec.status === "queued") {
1232
1587
  const idx = this.#resumeQueue.findIndex(e => e.subagentId === rec.subagentId);
1588
+ const queuedSeq = rec.queued?.seq;
1233
1589
  if (idx !== -1) this.#resumeQueue.splice(idx, 1);
1234
1590
  rec.status = "cancelled";
1591
+ if (queuedSeq !== undefined)
1592
+ this.#publishQueuedTerminal(rec.subagentId, `queued:${rec.subagentId}:${queuedSeq}`, "cancelled");
1235
1593
  rec.queued = undefined;
1236
1594
  this.#subagentProgress.delete(rec.subagentId);
1237
1595
  this.#notifyChange();
@@ -1464,16 +1822,18 @@ export class AsyncJobManager {
1464
1822
  acknowledgeDeliveries(jobIds: string[]): number {
1465
1823
  const uniqueJobIds = Array.from(new Set(jobIds.map(id => id.trim()).filter(id => id.length > 0)));
1466
1824
  if (uniqueJobIds.length === 0) return 0;
1467
-
1468
1825
  for (const jobId of uniqueJobIds) {
1469
- this.#suppressedDeliveries.add(jobId);
1826
+ const currentJob = this.#jobs.get(jobId);
1827
+ if (currentJob) this.#suppressedDeliveries.add(currentJob.generation);
1828
+ for (const delivery of [...this.#deliveries, ...this.#inFlightDeliveries]) {
1829
+ if (delivery.jobId === jobId) this.#suppressedDeliveries.add(delivery.generation);
1830
+ }
1470
1831
  }
1471
-
1472
1832
  const before = this.#deliveries.length;
1473
1833
  this.#deliveries.splice(
1474
1834
  0,
1475
1835
  this.#deliveries.length,
1476
- ...this.#deliveries.filter(delivery => !this.#isDeliveryAcknowledged(delivery.jobId)),
1836
+ ...this.#deliveries.filter(delivery => !this.#isDeliveryAcknowledged(delivery.jobId, delivery.generation)),
1477
1837
  );
1478
1838
  return before - this.#deliveries.length;
1479
1839
  }
@@ -1635,6 +1995,8 @@ export class AsyncJobManager {
1635
1995
  this.#deadLetteredDeliveries.clear();
1636
1996
  this.#deadLetteredDeliveryOwners.clear();
1637
1997
  this.#suppressedDeliveries.clear();
1998
+ this.#deliveryAckOwners.clear();
1999
+ this.#waitGenerationAliases.clear();
1638
2000
  this.#watchedJobs.clear();
1639
2001
  this.#outputState.clear();
1640
2002
  this.#ownerCleanups.clear();
@@ -1700,12 +2062,14 @@ export class AsyncJobManager {
1700
2062
  this.#recordMonitorTombstone(jobId);
1701
2063
  this.#runLifecycle(jobId, "evict");
1702
2064
  this.#purgeTerminalSubagentStateForJob(jobId);
2065
+ const job = this.#jobs.get(jobId);
1703
2066
  this.#jobs.delete(jobId);
1704
2067
  this.#lifecycles.delete(jobId);
1705
2068
  this.#lifecyclePhases.delete(jobId);
1706
2069
  this.#deadLetteredDeliveries.delete(jobId);
1707
2070
  this.#deadLetteredDeliveryOwners.delete(jobId);
1708
2071
  this.#suppressedDeliveries.delete(jobId);
2072
+ if (job) this.#publishedTerminalGenerations.delete(job.generation);
1709
2073
  this.#watchedJobs.delete(jobId);
1710
2074
  this.#outputState.delete(jobId);
1711
2075
  }
@@ -1719,17 +2083,21 @@ export class AsyncJobManager {
1719
2083
 
1720
2084
  #filterDeliveries(filter?: AsyncJobFilter): AsyncJobDelivery[] {
1721
2085
  const ownerId = filter?.ownerId;
1722
- if (!ownerId) return this.#deliveries.filter(delivery => !this.isDeliverySuppressed(delivery.jobId));
2086
+ if (!ownerId)
2087
+ return this.#deliveries.filter(delivery => !this.isDeliverySuppressed(delivery.jobId, delivery.generation));
1723
2088
  return this.#deliveries.filter(
1724
- delivery => delivery.ownerId === ownerId && !this.isDeliverySuppressed(delivery.jobId),
2089
+ delivery => delivery.ownerId === ownerId && !this.isDeliverySuppressed(delivery.jobId, delivery.generation),
1725
2090
  );
1726
2091
  }
1727
2092
 
1728
2093
  #filterInFlightDeliveries(filter?: AsyncJobFilter): AsyncJobDelivery[] {
1729
2094
  const ownerId = filter?.ownerId;
1730
- if (!ownerId) return this.#inFlightDeliveries.filter(delivery => !this.isDeliverySuppressed(delivery.jobId));
2095
+ if (!ownerId)
2096
+ return this.#inFlightDeliveries.filter(
2097
+ delivery => !this.isDeliverySuppressed(delivery.jobId, delivery.generation),
2098
+ );
1731
2099
  return this.#inFlightDeliveries.filter(
1732
- delivery => delivery.ownerId === ownerId && !this.isDeliverySuppressed(delivery.jobId),
2100
+ delivery => delivery.ownerId === ownerId && !this.isDeliverySuppressed(delivery.jobId, delivery.generation),
1733
2101
  );
1734
2102
  }
1735
2103
 
@@ -1739,7 +2107,8 @@ export class AsyncJobManager {
1739
2107
 
1740
2108
  #hasDeliverable(): boolean {
1741
2109
  return this.#deliveries.some(
1742
- delivery => !this.isDeliverySuppressed(delivery.jobId) && !this.#isDeliveryFenced(delivery),
2110
+ delivery =>
2111
+ !this.isDeliverySuppressed(delivery.jobId, delivery.generation) && !this.#isDeliveryFenced(delivery),
1743
2112
  );
1744
2113
  }
1745
2114
 
@@ -1748,7 +2117,8 @@ export class AsyncJobManager {
1748
2117
  let selected: AsyncJobDelivery | undefined;
1749
2118
  for (const delivery of this.#deliveries) {
1750
2119
  if (delivery.ownerId !== filter.ownerId) continue;
1751
- if (this.isDeliverySuppressed(delivery.jobId) || this.#isDeliveryFenced(delivery)) continue;
2120
+ if (this.isDeliverySuppressed(delivery.jobId, delivery.generation) || this.#isDeliveryFenced(delivery))
2121
+ continue;
1752
2122
  if (!selected || delivery.nextAttemptAt < selected.nextAttemptAt) {
1753
2123
  selected = delivery;
1754
2124
  }
@@ -1770,18 +2140,21 @@ export class AsyncJobManager {
1770
2140
  const index = this.#deliveries.indexOf(selected);
1771
2141
  if (index === -1) continue;
1772
2142
  this.#deliveries.splice(index, 1);
1773
- if (this.isDeliverySuppressed(selected.jobId)) continue;
2143
+ if (this.isDeliverySuppressed(selected.jobId, selected.generation)) continue;
1774
2144
 
1775
2145
  return this.#waitForDeliveryPromise(this.#deliverDelivery(selected), deadline);
1776
2146
  }
1777
2147
  }
1778
2148
 
1779
- #isDeliveryAcknowledged(jobId: string): boolean {
1780
- return this.#suppressedDeliveries.has(jobId);
2149
+ #isDeliveryAcknowledged(jobId: string, generation?: string): boolean {
2150
+ return (
2151
+ (generation !== undefined && this.#suppressedDeliveries.has(generation)) ||
2152
+ this.#suppressedDeliveries.has(jobId)
2153
+ );
1781
2154
  }
1782
2155
 
1783
- isDeliverySuppressed(jobId: string): boolean {
1784
- return this.#isDeliveryAcknowledged(jobId) || this.#watchedJobs.has(jobId);
2156
+ isDeliverySuppressed(jobId: string, generation?: string): boolean {
2157
+ return this.#isDeliveryAcknowledged(jobId, generation) || this.#watchedJobs.has(jobId);
1785
2158
  }
1786
2159
 
1787
2160
  #pruneEvictedDeadLetters(): void {
@@ -1794,11 +2167,13 @@ export class AsyncJobManager {
1794
2167
 
1795
2168
  #recordDeadLetter(delivery: AsyncJobDelivery): void {
1796
2169
  this.#pruneEvictedDeadLetters();
1797
- if (!this.#jobs.has(delivery.jobId)) return;
2170
+ const currentJob = this.#jobs.get(delivery.jobId);
2171
+ if (!currentJob || currentJob.generation !== delivery.generation) return;
1798
2172
  this.#deadLetteredDeliveries.delete(delivery.jobId);
1799
2173
  this.#deadLetteredDeliveryOwners.delete(delivery.jobId);
1800
2174
  this.#deadLetteredDeliveries.set(delivery.jobId, {
1801
2175
  jobId: delivery.jobId,
2176
+ generation: delivery.generation,
1802
2177
  attempt: delivery.attempt,
1803
2178
  lastError: delivery.lastError,
1804
2179
  });
@@ -1812,19 +2187,19 @@ export class AsyncJobManager {
1812
2187
  }
1813
2188
 
1814
2189
  #enqueueDelivery(jobId: string, text: string): void {
1815
- // Skip delivery if already acknowledged
1816
- if (this.#isDeliveryAcknowledged(jobId)) {
1817
- return;
1818
- }
2190
+ const job = this.#jobs.get(jobId);
2191
+ if (!job || this.#isDeliveryAcknowledged(jobId, job.generation)) return;
1819
2192
  const deliveryText = this.#boundedDeliveryText(text);
1820
2193
  this.#deliveries.push({
1821
2194
  jobId,
2195
+ generation: job.generation,
2196
+ job,
1822
2197
  text: deliveryText.text,
1823
2198
  originalBytes: deliveryText.originalBytes,
1824
2199
  truncated: deliveryText.truncated,
1825
2200
  attempt: 0,
1826
2201
  nextAttemptAt: Date.now(),
1827
- ownerId: this.#jobs.get(jobId)?.ownerId,
2202
+ ownerId: job.ownerId,
1828
2203
  });
1829
2204
  while (this.#deliveries.length > DEFAULT_MAX_DELIVERY_QUEUE) {
1830
2205
  const dropped = this.#deliveries.shift();
@@ -1867,7 +2242,8 @@ export class AsyncJobManager {
1867
2242
  async #runDeliveryLoop(): Promise<void> {
1868
2243
  while (this.#deliveries.length > 0) {
1869
2244
  const delivery = this.#deliveries.find(
1870
- candidate => !this.isDeliverySuppressed(candidate.jobId) && !this.#isDeliveryFenced(candidate),
2245
+ candidate =>
2246
+ !this.isDeliverySuppressed(candidate.jobId, candidate.generation) && !this.#isDeliveryFenced(candidate),
1871
2247
  );
1872
2248
  if (!delivery) return;
1873
2249
  const waitMs = delivery.nextAttemptAt - Date.now();
@@ -1876,7 +2252,8 @@ export class AsyncJobManager {
1876
2252
  }
1877
2253
  const index = this.#deliveries.indexOf(delivery);
1878
2254
  if (index === -1) continue;
1879
- if (this.isDeliverySuppressed(delivery.jobId) || this.#isDeliveryFenced(delivery)) continue;
2255
+ if (this.isDeliverySuppressed(delivery.jobId, delivery.generation) || this.#isDeliveryFenced(delivery))
2256
+ continue;
1880
2257
 
1881
2258
  this.#deliveries.splice(index, 1);
1882
2259
  await this.#deliverDelivery(delivery);
@@ -1887,7 +2264,10 @@ export class AsyncJobManager {
1887
2264
  const promise = (async () => {
1888
2265
  this.#inFlightDeliveries.push(delivery);
1889
2266
  try {
1890
- await this.#onJobComplete(delivery.jobId, delivery.text, this.#jobs.get(delivery.jobId));
2267
+ const currentJob = this.#jobs.get(delivery.jobId);
2268
+ if (currentJob && currentJob.generation !== delivery.generation) return;
2269
+ if (this.#isDeliveryAcknowledged(delivery.jobId, delivery.generation)) return;
2270
+ await this.#onJobComplete(delivery.jobId, delivery.text, delivery.job);
1891
2271
  } catch (error) {
1892
2272
  delivery.attempt += 1;
1893
2273
  delivery.lastError = error instanceof Error ? error.message : String(error);
@@ -1900,7 +2280,11 @@ export class AsyncJobManager {
1900
2280
  });
1901
2281
  } else {
1902
2282
  delivery.nextAttemptAt = Date.now() + this.#getRetryDelay(delivery.attempt);
1903
- if (!this.#isDeliveryAcknowledged(delivery.jobId)) {
2283
+ const currentJob = this.#jobs.get(delivery.jobId);
2284
+ if (
2285
+ currentJob?.generation === delivery.generation &&
2286
+ !this.#isDeliveryAcknowledged(delivery.jobId, delivery.generation)
2287
+ ) {
1904
2288
  this.#deliveries.push(delivery);
1905
2289
  }
1906
2290
  logger.warn("Async job completion delivery failed", {