@danypops/papyrus 0.46.2 → 0.47.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.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/cli/task-command.ts +49 -8
- package/src/cli.ts +10 -8
- package/src/constants.ts +3 -1
- package/src/db.ts +44 -0
- package/src/domain-service-shared.ts +2 -3
- package/src/handlers/shared.ts +8 -0
- package/src/handlers/tasks.ts +164 -72
- package/src/id-migration.ts +2 -0
- package/src/index.ts +9 -1
- package/src/modules/tasks.ts +33 -10
- package/src/service.ts +4 -1
- package/src/stores/sqlite-task-mutation-request-store.ts +98 -0
- package/src/stores/task-mutation-request-store.ts +89 -0
- package/src/task/task-service.ts +402 -55
package/src/task/task-service.ts
CHANGED
|
@@ -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 {
|
|
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 = {}):
|
|
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 = {}):
|
|
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
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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(
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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(
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
const
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
|
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(() =>
|
|
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 {
|
|
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
|