@danypops/papyrus 0.44.11 → 0.45.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.
- package/package.json +1 -1
- package/src/cli/task-command.ts +2 -1
- package/src/constants.ts +11 -0
- package/src/domain/gate.ts +11 -0
- package/src/domain/task-event.ts +1 -0
- package/src/handlers/discuss.ts +13 -2
- package/src/handlers/shared.ts +11 -2
- package/src/handlers/tasks.ts +41 -4
- package/src/modules/tasks.ts +2 -0
- package/src/ops.ts +3 -2
- package/src/service.ts +1 -0
- package/src/task/task-service.ts +20 -4
package/package.json
CHANGED
package/src/cli/task-command.ts
CHANGED
|
@@ -747,7 +747,7 @@ const startCommand = buildCommand({
|
|
|
747
747
|
docs: { brief: "Lifecycle transition: todo -> in-progress" },
|
|
748
748
|
});
|
|
749
749
|
|
|
750
|
-
function buildSimpleTransitionCommand(action: "submit" | "reject" | "retry" | "cancel", brief: string) {
|
|
750
|
+
function buildSimpleTransitionCommand(action: "submit" | "reject" | "retry" | "cancel" | "reopen", brief: string) {
|
|
751
751
|
return buildCommand({
|
|
752
752
|
func: async function (this: TaskContext, flags: { sessionId?: string }, id: string) {
|
|
753
753
|
const artifact = await this.client.call<Record<string, unknown>, CliArtifact>(`tasks.${action}` as OperationName, {
|
|
@@ -868,6 +868,7 @@ const app = buildApplication(
|
|
|
868
868
|
reject: buildSimpleTransitionCommand("reject", "Lifecycle transition: review -> rejected"),
|
|
869
869
|
retry: buildSimpleTransitionCommand("retry", "Lifecycle transition: rejected -> in-progress"),
|
|
870
870
|
cancel: buildSimpleTransitionCommand("cancel", "Lifecycle transition to canceled"),
|
|
871
|
+
reopen: buildSimpleTransitionCommand("reopen", "Lifecycle transition: canceled -> todo"),
|
|
871
872
|
"cancel-subtree": cancelSubtreeCommand,
|
|
872
873
|
depend: buildDependencyCommand(
|
|
873
874
|
"tasks.depend",
|
package/src/constants.ts
CHANGED
|
@@ -16,6 +16,17 @@ export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
|
16
16
|
export const DB_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60_000;
|
|
17
17
|
export const GATE_COMMAND_TIMEOUT_MS = 30_000;
|
|
18
18
|
export const GATE_TEST_TIMEOUT_MS = 60_000;
|
|
19
|
+
/**
|
|
20
|
+
* Ceiling a Gate's own explicit `timeoutMs` (domain/gate.ts) may request, overriding
|
|
21
|
+
* GATE_COMMAND_TIMEOUT_MS/GATE_TEST_TIMEOUT_MS for that one gate -- e.g. a task whose gate is a
|
|
22
|
+
* full monorepo test run that legitimately takes longer than either type default. Real, confirmed
|
|
23
|
+
* bug this exists for: a caller had no way to declare a longer-than-default gate timeout at all --
|
|
24
|
+
* `tasks.set_gates` silently accepted and dropped an experimental `timeoutMs` field before this.
|
|
25
|
+
* handlers/tasks.ts's GATE_OPERATION_LIMITS derives its own outer Vehicle transport deadline from
|
|
26
|
+
* this same constant, so the outer deadline can never fire strictly before a single gate honoring
|
|
27
|
+
* this ceiling has had a chance to.
|
|
28
|
+
*/
|
|
29
|
+
export const GATE_TIMEOUT_MAX_MS = 300_000;
|
|
19
30
|
export const GATE_OUTPUT_LIMIT = 200;
|
|
20
31
|
export const GATE_MAX_BUFFER_BYTES = 1_048_576;
|
|
21
32
|
export const GATE_FILE_MAX_BYTES = 1_048_576;
|
package/src/domain/gate.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { GATE_TIMEOUT_MAX_MS } from "../constants.ts";
|
|
2
|
+
|
|
1
3
|
export const GATE_TYPES = ["file-exists", "command", "contains", "test"] as const;
|
|
2
4
|
export type GateType = (typeof GATE_TYPES)[number];
|
|
3
5
|
|
|
@@ -5,6 +7,8 @@ export interface Gate {
|
|
|
5
7
|
type: GateType;
|
|
6
8
|
target: string;
|
|
7
9
|
expect?: string;
|
|
10
|
+
/** Overrides GATE_COMMAND_TIMEOUT_MS/GATE_TEST_TIMEOUT_MS for this one "command"/"test" gate, bounded by GATE_TIMEOUT_MAX_MS (see its own doc comment). */
|
|
11
|
+
timeoutMs?: number;
|
|
8
12
|
}
|
|
9
13
|
|
|
10
14
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -29,10 +33,17 @@ export function validateGates(value: unknown): Gate[] {
|
|
|
29
33
|
if (entry.expect !== undefined && typeof entry.expect !== "string") {
|
|
30
34
|
throw new Error(`gate at index ${index} expect must be a string`);
|
|
31
35
|
}
|
|
36
|
+
if (
|
|
37
|
+
entry.timeoutMs !== undefined &&
|
|
38
|
+
(!Number.isInteger(entry.timeoutMs) || (entry.timeoutMs as number) < 1_000 || (entry.timeoutMs as number) > GATE_TIMEOUT_MAX_MS)
|
|
39
|
+
) {
|
|
40
|
+
throw new Error(`gate at index ${index} timeoutMs must be an integer between 1000 and ${GATE_TIMEOUT_MAX_MS}`);
|
|
41
|
+
}
|
|
32
42
|
return {
|
|
33
43
|
type: entry.type as GateType,
|
|
34
44
|
target: entry.target,
|
|
35
45
|
...(typeof entry.expect === "string" ? { expect: entry.expect } : {}),
|
|
46
|
+
...(typeof entry.timeoutMs === "number" ? { timeoutMs: entry.timeoutMs } : {}),
|
|
36
47
|
};
|
|
37
48
|
});
|
|
38
49
|
}
|
package/src/domain/task-event.ts
CHANGED
package/src/handlers/discuss.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { VehicleContentBlock } from "@danypops/vehicle-core";
|
|
|
17
17
|
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
18
18
|
import type { Artifact } from "../artifact/artifact.ts";
|
|
19
19
|
import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
20
|
+
import { DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH, DISCUSSION_OPTION_MAX_LENGTH, DISCUSSION_OPTIONS_MAX_COUNT } from "../constants.ts";
|
|
20
21
|
import type { DiscussionAndRounds, Discussions } from "../discussion/discussion-service.ts";
|
|
21
22
|
import { DISCUSSION_SUBTYPE, type DiscussionRound } from "../domain/discussion.ts";
|
|
22
23
|
import { discussOperations } from "../modules/discuss.ts";
|
|
@@ -118,6 +119,16 @@ function defaultActorToAgent(input: Record<string, unknown>): void {
|
|
|
118
119
|
|
|
119
120
|
const optionsUnionSchema = { type: "array" } as const;
|
|
120
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Shared suffix for open/reply's own description -- interpolates the real, enforced bounds
|
|
124
|
+
* (domain/discussion.ts's validateDiscussionOptions) rather than a hand-typed number that can
|
|
125
|
+
* silently drift out of sync with the actual limit. A prior version of this description stated
|
|
126
|
+
* the option count bound but not the per-option/per-description character bound at all --
|
|
127
|
+
* confirmed live: a caller had no way to know a 250-character description would be rejected
|
|
128
|
+
* until it already had been (see discuss-vehicle.test.ts's oversized-description regression).
|
|
129
|
+
*/
|
|
130
|
+
const OPTION_BOUNDS_TEXT = `Each option is at most ${DISCUSSION_OPTION_MAX_LENGTH} characters (up to ${DISCUSSION_OPTIONS_MAX_COUNT} total); each description is at most ${DISCUSSION_OPTION_DESCRIPTION_MAX_LENGTH} characters.`;
|
|
131
|
+
|
|
121
132
|
export function registerDiscussVehicleOperations(registry: VehicleRegistry, discussions: Discussions, artifacts: ArtifactStore): void {
|
|
122
133
|
const moduleOperations = new Map(discussOperations(discussions).map((op) => [op.name, op]));
|
|
123
134
|
const call = <Output>(name: string, input: Record<string, unknown>): Output => moduleOperations.get(name)!.execute(input) as Output;
|
|
@@ -152,7 +163,7 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
|
|
|
152
163
|
|
|
153
164
|
define(
|
|
154
165
|
"open",
|
|
155
|
-
|
|
166
|
+
`Opens a new Discussion and starts round 1. Optionally poses a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several) -- each option a bare string (self-evident) or {title, description} (a real tradeoff worth spelling out; description REQUIRED once there are 3+ options). ${OPTION_BOUNDS_TEXT} Optionally blocks one or more Tasks immediately via blocks_task_ids/blocks_task_names. Pass live:true to get a human's answer synchronously in this same call, via an interactive prompt -- only takes effect with an interactive UI available, otherwise degrades silently to the normal durably-recorded round.`,
|
|
156
167
|
"local-write",
|
|
157
168
|
{
|
|
158
169
|
title: stringProp,
|
|
@@ -182,7 +193,7 @@ export function registerDiscussVehicleOperations(registry: VehicleRegistry, disc
|
|
|
182
193
|
|
|
183
194
|
define(
|
|
184
195
|
"reply",
|
|
185
|
-
|
|
196
|
+
`Adds a round to an existing Discussion. Refused once deferred or settled -- resume first. Answers a currently pending posed choice via \`selected\` (validated against it), or poses a new choice via options/options_mode. ${OPTION_BOUNDS_TEXT} Prefer \`name\` over \`id\`. Pass live:true to get a human's answer synchronously in this same call, via the pending choice's picker if one was posed, otherwise a freeform question -- only takes effect with an interactive UI available, otherwise degrades silently to the normal durably-recorded round.`,
|
|
186
197
|
"local-write",
|
|
187
198
|
{
|
|
188
199
|
id: stringProp,
|
package/src/handlers/shared.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
defineVehicleSchema,
|
|
10
10
|
type VehicleContentBlock,
|
|
11
11
|
VehicleError,
|
|
12
|
+
type VehicleLimits,
|
|
12
13
|
type VehicleOperationContext,
|
|
13
14
|
type VehicleSchemaCodec,
|
|
14
15
|
} from "@danypops/vehicle-core";
|
|
@@ -244,6 +245,14 @@ export type DefineOperation = (
|
|
|
244
245
|
required: readonly string[],
|
|
245
246
|
resolve: (input: Record<string, unknown>) => Record<string, unknown>,
|
|
246
247
|
execute?: (input: Record<string, unknown>, context: VehicleOperationContext<Record<string, unknown>>) => unknown,
|
|
248
|
+
/**
|
|
249
|
+
* Overrides this one operation's own Vehicle transport limits, distinct from every other
|
|
250
|
+
* operation this same createOperationDefiner call produces. For an operation that shells out
|
|
251
|
+
* to and waits on a real external command (e.g. tasks.run_gates/tasks.complete) rather than an
|
|
252
|
+
* instant CRUD read/write -- see handlers/tasks.ts's GATE_OPERATION_LIMITS for the motivating
|
|
253
|
+
* case. Omit to keep the definer's own default limits, unchanged for every other action.
|
|
254
|
+
*/
|
|
255
|
+
limits?: VehicleLimits,
|
|
247
256
|
) => void;
|
|
248
257
|
|
|
249
258
|
const STANDARD_OPERATION_LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
|
|
@@ -262,7 +271,7 @@ export function createOperationDefiner(
|
|
|
262
271
|
permissions: readonly [string, string],
|
|
263
272
|
defaultCall: (name: string, input: Record<string, unknown>) => unknown,
|
|
264
273
|
): DefineOperation {
|
|
265
|
-
return (action, description, effect, properties, required, resolve, execute) => {
|
|
274
|
+
return (action, description, effect, properties, required, resolve, execute, limits) => {
|
|
266
275
|
const operation = defineVehicleOperation({
|
|
267
276
|
name: `${domain}.${action}`,
|
|
268
277
|
version: 1,
|
|
@@ -272,7 +281,7 @@ export function createOperationDefiner(
|
|
|
272
281
|
permissions: [...permissions],
|
|
273
282
|
effect,
|
|
274
283
|
idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
|
|
275
|
-
limits: STANDARD_OPERATION_LIMITS,
|
|
284
|
+
limits: limits ?? STANDARD_OPERATION_LIMITS,
|
|
276
285
|
});
|
|
277
286
|
registry.register(
|
|
278
287
|
owner,
|
package/src/handlers/tasks.ts
CHANGED
|
@@ -19,8 +19,10 @@
|
|
|
19
19
|
*
|
|
20
20
|
* remove/remove_subtree/restore are not duplicated here -- see ./artifact-trash-vehicle.ts.
|
|
21
21
|
*/
|
|
22
|
+
import type { VehicleLimits } from "@danypops/vehicle-core";
|
|
22
23
|
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
23
24
|
import type { ArtifactStore } from "../artifact/artifact-store.ts";
|
|
25
|
+
import { GATE_TIMEOUT_MAX_MS } from "../constants.ts";
|
|
24
26
|
import type { TaskViewMode } from "../domain/task-scope.ts";
|
|
25
27
|
import { tasksOperations } from "../modules/tasks.ts";
|
|
26
28
|
import type { SessionIdentity } from "../session-identity/session-identity-service.ts";
|
|
@@ -40,7 +42,31 @@ import {
|
|
|
40
42
|
} from "./shared.ts";
|
|
41
43
|
|
|
42
44
|
const OWNER = "tasks";
|
|
43
|
-
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* tasks.run_gates/tasks.complete's own Vehicle transport limits, distinct from every other
|
|
48
|
+
* tasks.* CRUD action's shared STANDARD_OPERATION_LIMITS (createOperationDefiner's own default,
|
|
49
|
+
* 5s). Those two operations shell out to and wait on a real, caller-configured external command
|
|
50
|
+
* (domain/gate.ts's Gate.timeoutMs) -- the shared 5s CRUD default was an accidental inheritance,
|
|
51
|
+
* not a deliberate choice, and aborted the RPC call for any gate command that took longer than a
|
|
52
|
+
* few seconds, even a genuinely successful one (confirmed live, twice, with hard numbers: a real
|
|
53
|
+
* ~13s gate failed deterministically every time; a ~28s gate flapped around a separate, unrelated
|
|
54
|
+
* 30s inner per-gate-type default).
|
|
55
|
+
*
|
|
56
|
+
* defaultTimeoutMs is derived from GATE_TIMEOUT_MAX_MS (the longest a single gate's own explicit
|
|
57
|
+
* timeoutMs may request) plus a buffer for process-spawn/RPC/serialization overhead, so the outer
|
|
58
|
+
* transport deadline can never fire strictly before a single gate honoring that ceiling has had a
|
|
59
|
+
* chance to. A task with SEVERAL gates each near that ceiling can still exceed this default in
|
|
60
|
+
* aggregate (gates run sequentially -- see ops.ts's runGatesAsync); there is no aggregate
|
|
61
|
+
* task-level gate-time budget yet. maxTimeoutMs gives a caller who knows its gates are
|
|
62
|
+
* collectively slower room to explicitly request a longer deadline.
|
|
63
|
+
*/
|
|
64
|
+
const GATE_OPERATION_LIMITS: VehicleLimits = {
|
|
65
|
+
defaultTimeoutMs: GATE_TIMEOUT_MAX_MS + 60_000,
|
|
66
|
+
maxTimeoutMs: GATE_TIMEOUT_MAX_MS * 4,
|
|
67
|
+
maxRequestBytes: 65_536,
|
|
68
|
+
maxResponseBytes: 262_144,
|
|
69
|
+
};
|
|
44
70
|
|
|
45
71
|
const objectProp = { type: "object" } as unknown as { type: string };
|
|
46
72
|
const arrayProp = { type: "array" } as unknown as { type: string };
|
|
@@ -218,7 +244,7 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
|
|
|
218
244
|
|
|
219
245
|
define(
|
|
220
246
|
"update",
|
|
221
|
-
"Recovers an accidentally-terminal task via status=todo + reason, or changes title/body/labels, without rewriting real history. Never touches gates -- use set_gates.",
|
|
247
|
+
"Recovers an accidentally-terminal task via status=todo + reason (only a task whose status was terminal at its own creation -- not one that reached canceled/rejected through a real, later transition; use tasks.reopen for that), or changes title/body/labels, without rewriting real history. Never touches gates -- use set_gates.",
|
|
222
248
|
"local-write",
|
|
223
249
|
{
|
|
224
250
|
id: stringProp,
|
|
@@ -427,7 +453,16 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
|
|
|
427
453
|
);
|
|
428
454
|
define(
|
|
429
455
|
"cancel",
|
|
430
|
-
"Lifecycle transition to canceled (terminal) from todo/in-progress/review/rejected.",
|
|
456
|
+
"Lifecycle transition to canceled (terminal) from todo/in-progress/review/rejected. Reversible via tasks.reopen if this turns out to be premature.",
|
|
457
|
+
"local-write",
|
|
458
|
+
{ id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
|
|
459
|
+
[],
|
|
460
|
+
resolveIdAndScope,
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
define(
|
|
464
|
+
"reopen",
|
|
465
|
+
"Lifecycle transition: canceled -> todo. For a task legitimately canceled through a normal transition (e.g. a deliberate pause/park) that should resume -- distinct from tasks.update's status:todo path, which only recovers a task that was terminal at its own creation (a caller mistake), never one canceled/rejected later.",
|
|
431
466
|
"local-write",
|
|
432
467
|
{ id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
|
|
433
468
|
[],
|
|
@@ -456,6 +491,7 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
|
|
|
456
491
|
const labels = labelsById(artifacts, dependencyIds);
|
|
457
492
|
return { ...result, content: [{ type: "text" as const, text: completionContentText(labels, result) }] };
|
|
458
493
|
},
|
|
494
|
+
GATE_OPERATION_LIMITS,
|
|
459
495
|
);
|
|
460
496
|
|
|
461
497
|
define(
|
|
@@ -484,6 +520,7 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
|
|
|
484
520
|
"No gates configured.";
|
|
485
521
|
return { gates, content: [{ type: "text" as const, text }] };
|
|
486
522
|
},
|
|
523
|
+
GATE_OPERATION_LIMITS,
|
|
487
524
|
);
|
|
488
525
|
|
|
489
526
|
define(
|
|
@@ -496,7 +533,7 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
|
|
|
496
533
|
);
|
|
497
534
|
define(
|
|
498
535
|
"set_gates",
|
|
499
|
-
"Replaces a Task's gate commands in full.",
|
|
536
|
+
"Replaces a Task's gate commands in full. Each gate is {type, target, expect?, timeoutMs?} -- timeoutMs overrides the default per-type command timeout (30s)/test timeout (60s) for a legitimately slower gate, up to a bounded ceiling.",
|
|
500
537
|
"local-write",
|
|
501
538
|
{ id: stringProp, name: stringProp, gates: arrayProp, project_root: stringProp },
|
|
502
539
|
["gates"],
|
package/src/modules/tasks.ts
CHANGED
|
@@ -86,6 +86,7 @@ export const TASKS_OPERATION_NAMES = [
|
|
|
86
86
|
"tasks.reject",
|
|
87
87
|
"tasks.retry",
|
|
88
88
|
"tasks.cancel",
|
|
89
|
+
"tasks.reopen",
|
|
89
90
|
"tasks.cancel_subtree",
|
|
90
91
|
"tasks.depend",
|
|
91
92
|
"tasks.undepend",
|
|
@@ -201,6 +202,7 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionI
|
|
|
201
202
|
define("tasks.reject", (input: OperationInput) => tasks.transition(string(input, "id"), "reject", eventContext(input))),
|
|
202
203
|
define("tasks.retry", (input: OperationInput) => tasks.transition(string(input, "id"), "retry", eventContext(input))),
|
|
203
204
|
define("tasks.cancel", (input: OperationInput) => tasks.transition(string(input, "id"), "cancel", eventContext(input))),
|
|
205
|
+
define("tasks.reopen", (input: OperationInput) => tasks.transition(string(input, "id"), "reopen", eventContext(input))),
|
|
204
206
|
define("tasks.cancel_subtree", (input: OperationInput) => tasks.cancelSubtree(string(input, "id"), eventContext(input))),
|
|
205
207
|
define("tasks.depend", (input: OperationInput) =>
|
|
206
208
|
tasks.depend(string(input, "id"), string(input, "dependency_id"), eventContext(input)),
|
package/src/ops.ts
CHANGED
|
@@ -601,8 +601,9 @@ function readBoundedGateFile(path: string): string {
|
|
|
601
601
|
/** Shared by the sync and async process-gate runners so "test" is never a second, independently
|
|
602
602
|
* maintained copy of "command"'s own command-template/timeout selection. */
|
|
603
603
|
function processGateCommand(gate: Gate): { command: string; timeout: number } {
|
|
604
|
-
if (gate.type === "test")
|
|
605
|
-
|
|
604
|
+
if (gate.type === "test")
|
|
605
|
+
return { command: `npx vitest run ${gate.target} --reporter=dot`, timeout: gate.timeoutMs ?? GATE_TEST_TIMEOUT_MS };
|
|
606
|
+
return { command: gate.target, timeout: gate.timeoutMs ?? GATE_COMMAND_TIMEOUT_MS };
|
|
606
607
|
}
|
|
607
608
|
|
|
608
609
|
/**
|
package/src/service.ts
CHANGED
|
@@ -371,6 +371,7 @@ function handlers(
|
|
|
371
371
|
"tasks.reject": forwardToModule("tasks.reject"),
|
|
372
372
|
"tasks.retry": forwardToModule("tasks.retry"),
|
|
373
373
|
"tasks.cancel": forwardToModule("tasks.cancel"),
|
|
374
|
+
"tasks.reopen": forwardToModule("tasks.reopen"),
|
|
374
375
|
"tasks.cancel_subtree": forwardToModule("tasks.cancel_subtree"),
|
|
375
376
|
"tasks.depend": forwardToModule("tasks.depend"),
|
|
376
377
|
"tasks.undepend": forwardToModule("tasks.undepend"),
|
package/src/task/task-service.ts
CHANGED
|
@@ -78,7 +78,7 @@ export interface CreateTaskInput {
|
|
|
78
78
|
projectSource?: TaskScopeSource;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
export type TaskTransition = "start" | "submit" | "reject" | "retry" | "cancel";
|
|
81
|
+
export type TaskTransition = "start" | "submit" | "reject" | "retry" | "cancel" | "reopen";
|
|
82
82
|
|
|
83
83
|
export interface TaskBlockage {
|
|
84
84
|
artifact: Artifact;
|
|
@@ -134,6 +134,17 @@ const TASK_TRANSITIONS: Record<TaskTransition, { from: TaskStatus[]; to: TaskSta
|
|
|
134
134
|
reject: { from: ["review"], to: "rejected" },
|
|
135
135
|
retry: { from: ["rejected"], to: "in-progress" },
|
|
136
136
|
cancel: { from: ["todo", "in-progress", "review", "rejected"], to: "canceled" },
|
|
137
|
+
/**
|
|
138
|
+
* Distinct from tasks.update's status:todo path (recoverCreation, below): that path only ever
|
|
139
|
+
* recovers a task whose entire history is a single "created" event already at a terminal status
|
|
140
|
+
* (a creation-time mistake) -- it deliberately refuses a task that reached canceled through a
|
|
141
|
+
* real, later transition. reopen is the missing counterpart for exactly that case: a task
|
|
142
|
+
* legitimately canceled (e.g. a deliberate "pause/park", since there is no direct
|
|
143
|
+
* in-progress -> todo transition) can be brought back to todo and driven through the normal
|
|
144
|
+
* lifecycle again, without rewriting its real history the way recoverCreation's own
|
|
145
|
+
* terminal-at-creation check exists to prevent.
|
|
146
|
+
*/
|
|
147
|
+
reopen: { from: ["canceled"], to: "todo" },
|
|
137
148
|
};
|
|
138
149
|
|
|
139
150
|
export class Tasks {
|
|
@@ -511,9 +522,14 @@ export class Tasks {
|
|
|
511
522
|
this.focusStore.set(id, context.sessionId);
|
|
512
523
|
}
|
|
513
524
|
const updated = this.artifacts.setStatus(id, transition.to)!;
|
|
514
|
-
const eventType = {
|
|
515
|
-
|
|
516
|
-
|
|
525
|
+
const eventType = {
|
|
526
|
+
start: "started",
|
|
527
|
+
submit: "submitted",
|
|
528
|
+
reject: "review_rejected",
|
|
529
|
+
retry: "retried",
|
|
530
|
+
cancel: "canceled",
|
|
531
|
+
reopen: "reopened",
|
|
532
|
+
}[action] as AppendTaskEvent["type"];
|
|
517
533
|
this.appendEvent({ taskId: id, type: eventType, fromStatus: task.status as TaskStatus, toStatus: transition.to }, context);
|
|
518
534
|
if (action === "start" || action === "retry") this.propagateProgressToAncestors(id, context);
|
|
519
535
|
if (action === "retry") this.focusStore.set(id, context.sessionId);
|