@danypops/papyrus 0.46.2 → 0.47.0

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.
@@ -12,6 +12,8 @@ import {
12
12
  TASK_FOCUS_STALE_AFTER_MS,
13
13
  TASK_LABEL_MAX_COUNT,
14
14
  TASK_LABEL_MAX_LENGTH,
15
+ TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH,
16
+ TASK_MUTATION_IDEMPOTENCY_RETENTION_MS,
15
17
  TASK_PROJECT_ALIAS_MAX_COUNT,
16
18
  TASK_PROJECT_LIST_MAX_RESULTS,
17
19
  TASK_PROJECT_NAME_MAX_LENGTH,
@@ -40,7 +42,7 @@ import {
40
42
  type TaskViewSelection,
41
43
  taskScopeLabel,
42
44
  } from "../domain/task-scope.ts";
43
- import { type TransitionTable, validateTransitionFrom } from "../domain-service-shared.ts";
45
+ import type { TransitionTable } from "../domain-service-shared.ts";
44
46
  import type { GateRunner } from "../stores/gate-runner.ts";
45
47
  import {
46
48
  InMemoryTaskCreateRequestStore,
@@ -50,6 +52,13 @@ import {
50
52
  import { InMemoryTaskEventStore, type TaskEventStore } from "../stores/task-event-store.ts";
51
53
  import { InMemoryTaskFocusStore, type TaskFocusStatus, type TaskFocusStore } from "../stores/task-focus-store.ts";
52
54
  import { InMemoryTaskLeaseStore, type TaskLeaseStore } from "../stores/task-lease-store.ts";
55
+ import {
56
+ InMemoryTaskMutationRequestStore,
57
+ TaskMutationIdempotencyConflictError,
58
+ TaskMutationPendingError,
59
+ type TaskMutationRequestRecord,
60
+ type TaskMutationRequestStore,
61
+ } from "../stores/task-mutation-request-store.ts";
53
62
  import { InMemoryTaskScopeStore, type TaskScopeStore } from "../stores/task-scope-store.ts";
54
63
  import { assertDependencyEdgeAllowed, TaskExecutionBoundExceededError } from "./task-execution.ts";
55
64
 
@@ -77,6 +86,48 @@ export type TaskStatus = TaskLifecycleStatus;
77
86
 
78
87
  export class TaskProjectNotFoundError extends Error {}
79
88
  export class TaskProjectAmbiguousError extends Error {}
89
+ export class TaskMutationReceiptNotFoundError extends Error {}
90
+
91
+ export class TaskInvalidTransitionError extends Error {
92
+ constructor(
93
+ readonly operation: string,
94
+ readonly currentStatus: string,
95
+ readonly intendedStatus: string,
96
+ readonly allowedActions: readonly string[],
97
+ readonly recovery: string,
98
+ ) {
99
+ super(`cannot ${operation} task from ${currentStatus}; intended status is ${intendedStatus}`);
100
+ }
101
+ }
102
+
103
+ export interface TaskMutationRequestContext {
104
+ key?: string;
105
+ caller?: string;
106
+ }
107
+
108
+ export interface TaskMutationMetadata {
109
+ changed: boolean;
110
+ operation: string;
111
+ currentStatus: string;
112
+ intendedStatus: string;
113
+ receiptId?: string;
114
+ replayed?: boolean;
115
+ }
116
+
117
+ export type TaskLifecycleMutationResult = Artifact & TaskMutationMetadata;
118
+
119
+ export interface TaskMutationReceiptView {
120
+ receiptId: string;
121
+ operation: string;
122
+ state: "pending" | "completed";
123
+ taskName?: string;
124
+ taskTitle?: string;
125
+ taskStatus?: string;
126
+ result?: unknown;
127
+ createdAt: string;
128
+ updatedAt: string;
129
+ expiresAt: string;
130
+ }
80
131
 
81
132
  export interface CreateTaskRequestContext {
82
133
  key?: string;
@@ -121,12 +172,14 @@ export interface TaskFocus {
121
172
  pauseReason?: string;
122
173
  }
123
174
 
175
+ export type TaskFocusMutationResult = TaskFocus & TaskMutationMetadata;
176
+
124
177
  export interface TaskCompletionOptions {
125
178
  focusSuccessor?: boolean;
126
179
  gateDeadlineMs?: number;
127
180
  }
128
181
 
129
- export interface TaskCompletion {
182
+ export interface TaskCompletion extends TaskMutationMetadata {
130
183
  artifact: Artifact;
131
184
  gates: GateResult[];
132
185
  checklist: ChecklistReview[];
@@ -190,8 +243,11 @@ export class Tasks {
190
243
  private readonly scopes: TaskScopeStore = new InMemoryTaskScopeStore(),
191
244
  private readonly leases: TaskLeaseStore = new InMemoryTaskLeaseStore(),
192
245
  private readonly createRequests: TaskCreateRequestStore = new InMemoryTaskCreateRequestStore(),
246
+ private readonly mutationRequests: TaskMutationRequestStore = new InMemoryTaskMutationRequestStore(),
193
247
  ) {}
194
248
 
249
+ private readonly completionFlights = new Map<string, Promise<TaskCompletion>>();
250
+
195
251
  private require(id: string): Artifact {
196
252
  const artifact = this.artifacts.get(id);
197
253
  if (!artifact) throw new Error(`task artifact "${id}" not found`);
@@ -545,28 +601,86 @@ export class Tasks {
545
601
  });
546
602
  }
547
603
 
548
- pauseFocus(context: TaskEventContext = {}): TaskFocus {
604
+ pauseFocus(context: TaskEventContext = {}, request: TaskMutationRequestContext = {}): TaskFocusMutationResult {
605
+ const inspection = this.prepareMutation<TaskFocusMutationResult>("pause", undefined, context, request, false);
606
+ if (inspection.replay) return inspection.replay;
607
+ const focus = this.focused({ sessionId: context.sessionId });
608
+ if (!focus) {
609
+ throw new TaskInvalidTransitionError(
610
+ "pause",
611
+ "none",
612
+ "paused",
613
+ ["focus"],
614
+ "Focus a non-terminal task before pausing; do not blindly retry pause.",
615
+ );
616
+ }
617
+ const prepared = inspection.pending ? inspection : this.prepareMutation<TaskFocusMutationResult>("pause", undefined, context, request);
618
+ if (focus.status === "paused") {
619
+ return this.completeMutation(prepared.record, {
620
+ ...focus,
621
+ changed: false,
622
+ operation: "pause",
623
+ currentStatus: "paused",
624
+ intendedStatus: "paused",
625
+ ...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
626
+ });
627
+ }
549
628
  return this.events.atomic(() => {
550
- const focus = this.focused({ sessionId: context.sessionId });
551
- if (!focus) throw new Error("no focused task");
552
629
  const state = this.focusStore.pause(focus.artifact.id, context.reason, context.sessionId);
553
630
  this.appendEvent({ taskId: focus.artifact.id, type: "focus_paused" }, context);
554
- return {
631
+ return this.completeMutation(prepared.record, {
555
632
  artifact: focus.artifact,
556
633
  status: state.status,
557
634
  updatedAt: state.updatedAt,
558
635
  ...(state.pauseReason ? { pauseReason: state.pauseReason } : {}),
559
- };
636
+ changed: true,
637
+ operation: "pause",
638
+ currentStatus: "paused",
639
+ intendedStatus: "paused",
640
+ ...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
641
+ });
560
642
  });
561
643
  }
562
644
 
563
- unpauseFocus(context: TaskEventContext = {}): TaskFocus {
645
+ unpauseFocus(context: TaskEventContext = {}, request: TaskMutationRequestContext = {}): TaskFocusMutationResult {
646
+ const inspection = this.prepareMutation<TaskFocusMutationResult>("unpause", undefined, context, request, false);
647
+ if (inspection.replay) return inspection.replay;
648
+ const focus = this.focused({ sessionId: context.sessionId });
649
+ if (!focus) {
650
+ throw new TaskInvalidTransitionError(
651
+ "unpause",
652
+ "none",
653
+ "active",
654
+ ["focus"],
655
+ "Focus a non-terminal task before resuming; do not blindly retry unpause.",
656
+ );
657
+ }
658
+ const prepared = inspection.pending
659
+ ? inspection
660
+ : this.prepareMutation<TaskFocusMutationResult>("unpause", undefined, context, request);
661
+ if (focus.status === "active") {
662
+ return this.completeMutation(prepared.record, {
663
+ ...focus,
664
+ changed: false,
665
+ operation: "unpause",
666
+ currentStatus: "active",
667
+ intendedStatus: "active",
668
+ ...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
669
+ });
670
+ }
564
671
  return this.events.atomic(() => {
565
- const focus = this.focused({ sessionId: context.sessionId });
566
- if (!focus) throw new Error("no focused task");
567
672
  const state = this.focusStore.unpause(focus.artifact.id, context.sessionId);
568
673
  this.appendEvent({ taskId: focus.artifact.id, type: "focus_unpaused" }, context);
569
- return { artifact: focus.artifact, status: state.status, updatedAt: state.updatedAt };
674
+ return this.completeMutation(prepared.record, {
675
+ artifact: focus.artifact,
676
+ status: state.status,
677
+ updatedAt: state.updatedAt,
678
+ changed: true,
679
+ operation: "unpause",
680
+ currentStatus: "active",
681
+ intendedStatus: "active",
682
+ ...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
683
+ });
570
684
  });
571
685
  }
572
686
 
@@ -625,21 +739,146 @@ export class Tasks {
625
739
  return this.leases.reapExpired(now());
626
740
  }
627
741
 
628
- transition(id: string, action: TaskTransition, context: TaskEventContext = {}): Artifact {
629
- return this.events.atomic(() => {
630
- const task = this.require(id);
631
- const transition = validateTransitionFrom("task", action, task.status, TASK_TRANSITIONS);
632
- if (action === "start") {
633
- const blocking = this.dependencyIds(id)
634
- .map((dependencyId) => this.require(dependencyId))
635
- .filter((dependency) => dependency.status !== "done");
636
- if (blocking.length > 0)
637
- throw new Error(
638
- `task "${task.title}" is blocked by dependencies: ${blocking.map((dependency) => `"${dependency.title}"`).join(", ")}`,
639
- );
640
- this.focusStore.set(id, context.sessionId);
742
+ private allowedLifecycleActions(status: string): string[] {
743
+ const actions = Object.entries(TASK_TRANSITIONS)
744
+ .filter(([, transition]) => transition.from.includes(status as TaskStatus))
745
+ .map(([action]) => action);
746
+ if (status === "review") actions.push("complete");
747
+ return actions;
748
+ }
749
+
750
+ private prepareMutation<Result>(
751
+ operation: string,
752
+ taskId: string | undefined,
753
+ payload: unknown,
754
+ request: TaskMutationRequestContext,
755
+ reserve = true,
756
+ ): { record?: TaskMutationRequestRecord; replay?: Result; pending?: boolean } {
757
+ const key = request.key?.trim();
758
+ if (request.key !== undefined && (!key || key.length > TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH)) {
759
+ throw new Error(`idempotency key must be between 1 and ${TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH} characters`);
760
+ }
761
+ if (!key) return {};
762
+ const now = new Date().toISOString();
763
+ const scope = request.caller?.trim() || "anonymous";
764
+ const requestHash = createHash("sha256").update(canonicalJson({ operation, taskId, payload })).digest("hex");
765
+ this.mutationRequests.prune(now);
766
+ const existing = this.mutationRequests.get(scope, key, now);
767
+ if (existing) {
768
+ if (existing.requestHash !== requestHash) {
769
+ throw new TaskMutationIdempotencyConflictError(`idempotency key "${key}" was already used with a different mutation payload`);
770
+ }
771
+ if (existing.state === "completed" && existing.responseJson !== undefined) {
772
+ const replay = JSON.parse(existing.responseJson) as Result;
773
+ return {
774
+ record: existing,
775
+ replay:
776
+ typeof replay === "object" && replay !== null && "changed" in replay
777
+ ? ({ ...replay, changed: false, replayed: true } as Result)
778
+ : replay,
779
+ };
780
+ }
781
+ return { record: existing, pending: true };
782
+ }
783
+ if (!reserve) return {};
784
+ const record: TaskMutationRequestRecord = {
785
+ scope,
786
+ key,
787
+ receiptId: crypto.randomUUID(),
788
+ ...(taskId === undefined ? {} : { taskId }),
789
+ operation,
790
+ requestHash,
791
+ state: "pending",
792
+ createdAt: now,
793
+ updatedAt: now,
794
+ expiresAt: new Date(Date.parse(now) + TASK_MUTATION_IDEMPOTENCY_RETENTION_MS).toISOString(),
795
+ };
796
+ this.mutationRequests.put(record);
797
+ return { record };
798
+ }
799
+
800
+ private rejectDifferentPendingMutation(taskId: string, operation: string, inspectionPending: boolean): void {
801
+ if (inspectionPending) return;
802
+ const pending = this.mutationRequests.findPending(taskId, operation, new Date().toISOString());
803
+ if (!pending) return;
804
+ throw new TaskMutationPendingError(
805
+ `an earlier ${operation} outcome is still pending; inspect tasks.mutation_status with its original idempotency_key before retrying`,
806
+ pending.receiptId,
807
+ pending.operation,
808
+ );
809
+ }
810
+
811
+ private completeMutation<Result>(record: TaskMutationRequestRecord | undefined, result: Result): Result {
812
+ if (!record) return result;
813
+ this.mutationRequests.complete(record.scope, record.key, JSON.stringify(result), new Date().toISOString());
814
+ return result;
815
+ }
816
+
817
+ mutationStatus(keyInput: string, caller?: string): TaskMutationReceiptView {
818
+ const key = keyInput.trim();
819
+ if (!key || key.length > TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH) {
820
+ throw new Error(`idempotency key must be between 1 and ${TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH} characters`);
821
+ }
822
+ const now = new Date().toISOString();
823
+ const record = this.mutationRequests.get(caller?.trim() || "anonymous", key, now);
824
+ if (!record) throw new TaskMutationReceiptNotFoundError("no retained task mutation receipt exists for this idempotency key");
825
+ const task = record.taskId ? this.artifacts.get(record.taskId) : null;
826
+ return {
827
+ receiptId: record.receiptId,
828
+ operation: record.operation,
829
+ state: record.state,
830
+ ...(task?.kind === "task" ? { taskName: task.alias, taskTitle: task.title, taskStatus: task.status } : {}),
831
+ ...(record.responseJson === undefined ? {} : { result: JSON.parse(record.responseJson) as unknown }),
832
+ createdAt: record.createdAt,
833
+ updatedAt: record.updatedAt,
834
+ expiresAt: record.expiresAt,
835
+ };
836
+ }
837
+
838
+ transition(
839
+ id: string,
840
+ action: TaskTransition,
841
+ context: TaskEventContext = {},
842
+ request: TaskMutationRequestContext = {},
843
+ ): TaskLifecycleMutationResult {
844
+ const task = this.require(id);
845
+ const intendedStatus = TASK_TRANSITIONS[action].to;
846
+ const inspection = this.prepareMutation<TaskLifecycleMutationResult>(action, id, context, request, false);
847
+ if (inspection.replay) return inspection.replay;
848
+ this.rejectDifferentPendingMutation(id, action, inspection.pending === true);
849
+ if (task.status !== intendedStatus && !TASK_TRANSITIONS[action].from.includes(task.status as TaskStatus)) {
850
+ throw new TaskInvalidTransitionError(
851
+ action,
852
+ task.status,
853
+ intendedStatus,
854
+ this.allowedLifecycleActions(task.status),
855
+ `Call tasks.show, then choose one allowed action; do not retry ${action} with a new key.`,
856
+ );
857
+ }
858
+ if (action === "start" && task.status !== intendedStatus) {
859
+ const blocking = this.dependencyIds(id)
860
+ .map((dependencyId) => this.require(dependencyId))
861
+ .filter((dependency) => dependency.status !== "done");
862
+ if (blocking.length > 0) {
863
+ throw new Error(
864
+ `task "${task.title}" is blocked by dependencies: ${blocking.map((dependency) => `"${dependency.title}"`).join(", ")}`,
865
+ );
641
866
  }
642
- const updated = this.artifacts.setStatus(id, transition.to)!;
867
+ }
868
+ const prepared = inspection.pending ? inspection : this.prepareMutation<TaskLifecycleMutationResult>(action, id, context, request);
869
+ if (task.status === intendedStatus) {
870
+ return this.completeMutation(prepared.record, {
871
+ ...this.show(id),
872
+ changed: false,
873
+ operation: action,
874
+ currentStatus: intendedStatus,
875
+ intendedStatus,
876
+ ...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
877
+ });
878
+ }
879
+ return this.events.atomic(() => {
880
+ if (action === "start") this.focusStore.set(id, context.sessionId);
881
+ const artifact = this.artifacts.setStatus(id, intendedStatus)!;
643
882
  const eventType = {
644
883
  start: "started",
645
884
  submit: "submitted",
@@ -648,11 +887,18 @@ export class Tasks {
648
887
  cancel: "canceled",
649
888
  reopen: "reopened",
650
889
  }[action] as AppendTaskEvent["type"];
651
- this.appendEvent({ taskId: id, type: eventType, fromStatus: task.status as TaskStatus, toStatus: transition.to }, context);
890
+ this.appendEvent({ taskId: id, type: eventType, fromStatus: task.status as TaskStatus, toStatus: intendedStatus }, context);
652
891
  if (action === "start" || action === "retry") this.propagateProgressToAncestors(id, context);
653
892
  if (action === "retry") this.focusStore.set(id, context.sessionId);
654
893
  if (action === "cancel") this.focusStore.clearEverywhere(id);
655
- return updated;
894
+ return this.completeMutation(prepared.record, {
895
+ ...artifact,
896
+ changed: true,
897
+ operation: action,
898
+ currentStatus: intendedStatus,
899
+ intendedStatus,
900
+ ...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
901
+ });
656
902
  });
657
903
  }
658
904
 
@@ -692,32 +938,84 @@ export class Tasks {
692
938
  return { canceled, skipped };
693
939
  }
694
940
 
695
- complete(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): TaskCompletion {
696
- const task = this.requireReview(id);
697
- this.requireNotBlocked(task);
698
- const attemptId = crypto.randomUUID();
941
+ complete(
942
+ id: string,
943
+ context: TaskEventContext = {},
944
+ options: TaskCompletionOptions = {},
945
+ request: TaskMutationRequestContext = {},
946
+ ): TaskCompletion {
947
+ const task = this.require(id);
948
+ const inspection = this.prepareMutation<TaskCompletion>("complete", id, { context, options }, request, false);
949
+ if (inspection.replay) return inspection.replay;
950
+ this.rejectDifferentPendingMutation(id, "complete", inspection.pending === true);
951
+ if (inspection.pending) {
952
+ throw new TaskMutationPendingError(
953
+ "completion outcome is still pending; inspect tasks.mutation_status and tasks.show before choosing another action",
954
+ inspection.record!.receiptId,
955
+ "complete",
956
+ );
957
+ }
958
+ if (task.status !== "review" && task.status !== "done") this.throwInvalidCompletion(task.status);
959
+ if (task.status === "review") this.requireNotBlocked(task);
960
+ const prepared = this.prepareMutation<TaskCompletion>("complete", id, { context, options }, request);
961
+ if (task.status === "done") return this.completeMutation(prepared.record, this.completedNoop(id, context, prepared.record));
962
+ const attemptId = prepared.record?.receiptId ?? crypto.randomUUID();
699
963
  this.events.atomic(() =>
700
964
  this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context),
701
965
  );
702
966
  const checklist = this.reviewChecklist(task);
703
967
  const results = this.gates.run(id, { cwd: this.scopes.get(id)?.projectRoot });
704
- return this.resolveCompletion(id, attemptId, results, checklist, context, options);
968
+ return this.resolveCompletion(id, attemptId, results, checklist, context, options, prepared.record);
705
969
  }
706
970
 
707
- async completeAsync(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): Promise<TaskCompletion> {
708
- const task = this.requireReview(id);
709
- this.requireNotBlocked(task);
710
- const attemptId = crypto.randomUUID();
711
- this.events.atomic(() =>
712
- this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context),
713
- );
714
- const checklist = this.reviewChecklist(task);
715
- // project_root, never the daemon's own inherited process cwd -- see GateRunOptions.cwd's doc
716
- // comment for the real incident this fixes (a command gate once tested the daemon's entire
717
- // home directory instead of the task's project and crashed the bun process outright).
718
- const results = await this.gates.runAsync(id, { deadlineMs: options.gateDeadlineMs, cwd: this.scopes.get(id)?.projectRoot });
719
- this.requireReview(id);
720
- return this.resolveCompletion(id, attemptId, results, checklist, context, options);
971
+ async completeAsync(
972
+ id: string,
973
+ context: TaskEventContext = {},
974
+ options: TaskCompletionOptions = {},
975
+ request: TaskMutationRequestContext = {},
976
+ ): Promise<TaskCompletion> {
977
+ const flightKey = id;
978
+ const existingFlight = this.completionFlights.get(flightKey);
979
+ if (existingFlight) {
980
+ await existingFlight;
981
+ return this.completeAsync(id, context, options, request);
982
+ }
983
+ const execute = async (): Promise<TaskCompletion> => {
984
+ const task = this.require(id);
985
+ const inspection = this.prepareMutation<TaskCompletion>("complete", id, { context, options }, request, false);
986
+ if (inspection.replay) return inspection.replay;
987
+ this.rejectDifferentPendingMutation(id, "complete", inspection.pending === true);
988
+ if (inspection.pending) {
989
+ throw new TaskMutationPendingError(
990
+ "completion outcome is still pending; inspect tasks.mutation_status and tasks.show before choosing another action",
991
+ inspection.record!.receiptId,
992
+ "complete",
993
+ );
994
+ }
995
+ if (task.status !== "review" && task.status !== "done") this.throwInvalidCompletion(task.status);
996
+ if (task.status === "review") this.requireNotBlocked(task);
997
+ const prepared = this.prepareMutation<TaskCompletion>("complete", id, { context, options }, request);
998
+ if (task.status === "done") return this.completeMutation(prepared.record, this.completedNoop(id, context, prepared.record));
999
+ const attemptId = prepared.record?.receiptId ?? crypto.randomUUID();
1000
+ this.events.atomic(() =>
1001
+ this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context),
1002
+ );
1003
+ const checklist = this.reviewChecklist(task);
1004
+ // project_root, never the daemon's own inherited process cwd -- see GateRunOptions.cwd's doc
1005
+ // comment for the real incident this fixes (a command gate once tested the daemon's entire
1006
+ // home directory instead of the task's project and crashed the bun process outright).
1007
+ const results = await this.gates.runAsync(id, { deadlineMs: options.gateDeadlineMs, cwd: this.scopes.get(id)?.projectRoot });
1008
+ const latest = this.require(id);
1009
+ if (latest.status !== "review") this.throwInvalidCompletion(latest.status);
1010
+ return this.resolveCompletion(id, attemptId, results, checklist, context, options, prepared.record);
1011
+ };
1012
+ const flight = execute();
1013
+ this.completionFlights.set(flightKey, flight);
1014
+ try {
1015
+ return await flight;
1016
+ } finally {
1017
+ this.completionFlights.delete(flightKey);
1018
+ }
721
1019
  }
722
1020
 
723
1021
  async runGates(id: string, context: TaskEventContext = {}): Promise<GateResult[]> {
@@ -950,6 +1248,32 @@ export class Tasks {
950
1248
  return ids;
951
1249
  }
952
1250
 
1251
+ private throwInvalidCompletion(currentStatus: string): never {
1252
+ throw new TaskInvalidTransitionError(
1253
+ "complete",
1254
+ currentStatus,
1255
+ "done",
1256
+ this.allowedLifecycleActions(currentStatus),
1257
+ "Call tasks.show, then choose an allowed action. Reuse the original idempotency_key only when recovering an unknown completion outcome.",
1258
+ );
1259
+ }
1260
+
1261
+ private completedNoop(id: string, context: TaskEventContext, record?: TaskMutationRequestRecord): TaskCompletion {
1262
+ return {
1263
+ artifact: this.show(id),
1264
+ gates: [],
1265
+ checklist: this.reviewChecklist(this.require(id)),
1266
+ completed: true,
1267
+ focused: this.active({ sessionId: context.sessionId }),
1268
+ blocked: [],
1269
+ changed: false,
1270
+ operation: "complete",
1271
+ currentStatus: "done",
1272
+ intendedStatus: "done",
1273
+ ...(record ? { receiptId: record.receiptId } : {}),
1274
+ };
1275
+ }
1276
+
953
1277
  private resolveCompletion(
954
1278
  id: string,
955
1279
  attemptId: string,
@@ -957,6 +1281,7 @@ export class Tasks {
957
1281
  checklist: ChecklistReview[],
958
1282
  context: TaskEventContext,
959
1283
  options: TaskCompletionOptions,
1284
+ record?: TaskMutationRequestRecord,
960
1285
  ): TaskCompletion {
961
1286
  const failed = gates.some((gate) => !gate.passed) || checklist.some((item) => !item.accepted);
962
1287
  if (failed) {
@@ -973,10 +1298,25 @@ export class Tasks {
973
1298
  },
974
1299
  context,
975
1300
  );
976
- return { artifact, gates, checklist, completed: false, focused: this.active({ sessionId: context.sessionId }), blocked: [] };
1301
+ return this.completeMutation(record, {
1302
+ artifact,
1303
+ gates,
1304
+ checklist,
1305
+ completed: false,
1306
+ focused: this.active({ sessionId: context.sessionId }),
1307
+ blocked: [],
1308
+ changed: true,
1309
+ operation: "complete",
1310
+ currentStatus: "rejected",
1311
+ intendedStatus: "done",
1312
+ ...(record ? { receiptId: record.receiptId } : {}),
1313
+ });
977
1314
  });
978
1315
  }
979
- return this.events.atomic(() => this.finish(id, attemptId, gates, checklist, context, options));
1316
+ return this.events.atomic(() => {
1317
+ const result = this.finish(id, attemptId, gates, checklist, context, options, record?.receiptId);
1318
+ return this.completeMutation(record, result);
1319
+ });
980
1320
  }
981
1321
 
982
1322
  private finish(
@@ -986,6 +1326,7 @@ export class Tasks {
986
1326
  checklist: ChecklistReview[],
987
1327
  context: TaskEventContext,
988
1328
  options: TaskCompletionOptions,
1329
+ receiptId?: string,
989
1330
  ): TaskCompletion {
990
1331
  const successorIds = this.relationships(id)
991
1332
  .filter((edge) => edge.relation === "depends_on" && edge.to === id)
@@ -1024,7 +1365,19 @@ export class Tasks {
1024
1365
  focused = successor;
1025
1366
  }
1026
1367
  }
1027
- return { artifact, gates, checklist, completed: true, focused, blocked };
1368
+ return {
1369
+ artifact,
1370
+ gates,
1371
+ checklist,
1372
+ completed: true,
1373
+ focused,
1374
+ blocked,
1375
+ changed: true,
1376
+ operation: "complete",
1377
+ currentStatus: "done",
1378
+ intendedStatus: "done",
1379
+ ...(receiptId ? { receiptId } : {}),
1380
+ };
1028
1381
  }
1029
1382
 
1030
1383
  private appendEvent(event: Omit<AppendTaskEvent, "actor" | "source">, context: TaskEventContext): void {
@@ -1037,12 +1390,6 @@ export class Tasks {
1037
1390
  });
1038
1391
  }
1039
1392
 
1040
- private requireReview(id: string): Artifact {
1041
- const task = this.require(id);
1042
- if (task.status !== "review") throw new Error(`cannot complete task from ${task.status}`);
1043
- return task;
1044
- }
1045
-
1046
1393
  /**
1047
1394
  * Discuss's forcing behavior (see discussions/discussion.ts): an active Discussion doc that
1048
1395
  * `blocks` this task refuses its completion until settled or deferred. A discussion whose