@akagilnc/pi-workflow-roles 0.1.2071 → 0.1.2076
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/role-runtime.ts +1 -3
- package/src/worker-role.ts +2 -0
- package/src/worker-submission-gates.ts +185 -132
package/package.json
CHANGED
package/src/role-runtime.ts
CHANGED
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
} from "./tool-execution-observation.ts";
|
|
24
24
|
import { registerEngineDetourTool } from "./engine-detour-tool.ts";
|
|
25
25
|
import { installPackageOwnedToolRegistration } from "./package-owned-tool-idle.ts";
|
|
26
|
-
import { installWorkerGitHooks } from "./worker-submission-gates.ts";
|
|
27
26
|
import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT } from "./receipt-delivery-policy.ts";
|
|
28
27
|
import { createOAuthKeepalive, type OAuthKeepaliveOptions } from "./oauth-keepalive.ts";
|
|
29
28
|
|
|
@@ -1177,10 +1176,9 @@ export function createRoleRuntimeExtension(
|
|
|
1177
1176
|
engineDetourRegistration = undefined;
|
|
1178
1177
|
}
|
|
1179
1178
|
}
|
|
1180
|
-
// Worker gates
|
|
1179
|
+
// Worker gates ①②: arm records baseline and runs private one-shot hook uninstall (ADR 0070).
|
|
1181
1180
|
// Parent session feeds #216 createRecordSession so baseline/bounce survive resume.
|
|
1182
1181
|
if (entry.role === "coder" || entry.role === "fixer") {
|
|
1183
|
-
installWorkerGitHooks(ctx.cwd);
|
|
1184
1182
|
if (entry.role === "coder") coder.armSubmissionGate(ctx.cwd, ctx.sessionManager);
|
|
1185
1183
|
else fixer.armSubmissionGate(ctx.cwd, ctx.sessionManager);
|
|
1186
1184
|
}
|
package/src/worker-role.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import {
|
|
30
30
|
createWorkerSubmissionGate,
|
|
31
31
|
WorkerCommitReminderError,
|
|
32
|
+
WorkerPrefixReminderError,
|
|
32
33
|
WorkerUnfinishedReasonReminderError,
|
|
33
34
|
} from "./worker-submission-gates.ts";
|
|
34
35
|
|
|
@@ -189,6 +190,7 @@ function assertAcceptableThroughHost(
|
|
|
189
190
|
} catch (error) {
|
|
190
191
|
if (
|
|
191
192
|
error instanceof WorkerCommitReminderError ||
|
|
193
|
+
error instanceof WorkerPrefixReminderError ||
|
|
192
194
|
error instanceof WorkerUnfinishedReasonReminderError
|
|
193
195
|
) {
|
|
194
196
|
throw error;
|
|
@@ -1,12 +1,6 @@
|
|
|
1
|
-
/** #242 worker gates
|
|
1
|
+
/** #242/#369 worker gates ①② at submission seam. Durability: ADR 0065 createRecordSession only. */
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import {
|
|
4
|
-
chmodSync,
|
|
5
|
-
existsSync,
|
|
6
|
-
mkdirSync,
|
|
7
|
-
readFileSync,
|
|
8
|
-
writeFileSync,
|
|
9
|
-
} from "node:fs";
|
|
3
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, rmdirSync, rmSync } from "node:fs";
|
|
10
4
|
import { resolve } from "node:path";
|
|
11
5
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
12
6
|
|
|
@@ -16,22 +10,36 @@ import {
|
|
|
16
10
|
WORKER_SUBMISSION_GATE_KIND,
|
|
17
11
|
} from "./sitian-record-entry.ts";
|
|
18
12
|
|
|
19
|
-
export const WORKER_COMMIT_SUBJECT_PREFIX = "ak-roles:";
|
|
20
|
-
/** Sitian kind for gate ① durable baseline / bounce records (single path segment, not a destination). */
|
|
21
13
|
export const WORKER_SUBMISSION_GATE_RECORD_KIND = WORKER_SUBMISSION_GATE_KIND;
|
|
22
14
|
export const WORKER_COMMIT_BASELINE_ENTRY_TYPE = "commit-baseline";
|
|
23
15
|
export const WORKER_COMMIT_REMINDER_BOUNCE_ENTRY_TYPE = "commit-reminder-bounce";
|
|
16
|
+
export const WORKER_PREFIX_REMINDER_BOUNCE_ENTRY_TYPE = "prefix-reminder-bounce";
|
|
24
17
|
|
|
25
18
|
const DONE = new Set(["completed", "partially_completed"]);
|
|
26
|
-
/**
|
|
19
|
+
/** Historical package hook ownership marker — uninstall criterion only. */
|
|
27
20
|
const HOOK_MARKER = "ak-roles: worker-submission-gates reference-transaction";
|
|
21
|
+
const HOOKS_DIR = "ak-roles-hooks";
|
|
22
|
+
const HOOK_FILE = "reference-transaction";
|
|
23
|
+
/** Open platform-prefix domain (constitution #10) — not a closed singleton. */
|
|
24
|
+
const PLATFORM_PREFIX = /^[A-Za-z][A-Za-z0-9_-]*:/;
|
|
25
|
+
const UNFINISHED_REASON_BOUNCE_LIMIT = 2;
|
|
28
26
|
|
|
29
27
|
export class WorkerCommitReminderError extends Error {
|
|
30
28
|
readonly code = "worker_commit_reminder" as const;
|
|
31
|
-
constructor() {
|
|
29
|
+
constructor() {
|
|
30
|
+
super("未观察到 commit");
|
|
31
|
+
this.name = "WorkerCommitReminderError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class WorkerPrefixReminderError extends Error {
|
|
36
|
+
readonly code = "worker_prefix_reminder" as const;
|
|
37
|
+
constructor() {
|
|
38
|
+
super("观察到缺前缀 commit,请重写后再交");
|
|
39
|
+
this.name = "WorkerPrefixReminderError";
|
|
40
|
+
}
|
|
32
41
|
}
|
|
33
42
|
|
|
34
|
-
/** #292 unfinished reason solicitation — same bounce shape as gate ①; in-session only. */
|
|
35
43
|
export class WorkerUnfinishedReasonReminderError extends Error {
|
|
36
44
|
readonly code = "worker_unfinished_reason_reminder" as const;
|
|
37
45
|
constructor() {
|
|
@@ -40,18 +48,6 @@ export class WorkerUnfinishedReasonReminderError extends Error {
|
|
|
40
48
|
}
|
|
41
49
|
}
|
|
42
50
|
|
|
43
|
-
const UNFINISHED_REASON_BOUNCE_LIMIT = 2;
|
|
44
|
-
|
|
45
|
-
function unfinishedReasonPresent(details?: unknown): boolean {
|
|
46
|
-
if (typeof details !== "object" || details === null) return false;
|
|
47
|
-
try {
|
|
48
|
-
const reason = (details as { reason?: unknown }).reason;
|
|
49
|
-
return typeof reason === "string" && reason.trim().length > 0;
|
|
50
|
-
} catch {
|
|
51
|
-
return false;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
51
|
export type WorkerSubmissionGateParent = RecordSessionParent;
|
|
56
52
|
|
|
57
53
|
function git(cwd: string, args: string[]): string {
|
|
@@ -68,125 +64,127 @@ function gitFile(file: string, args: string[]): string {
|
|
|
68
64
|
}).trim();
|
|
69
65
|
}
|
|
70
66
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
prefix=${WORKER_COMMIT_SUBJECT_PREFIX}
|
|
77
|
-
while read -r old new ref; do
|
|
78
|
-
case $ref in refs/heads/*|HEAD) ;; *) continue ;; esac
|
|
79
|
-
[ -n "$new" ] && [ -n "$(printf %s "$new" | tr -d 0)" ] || continue
|
|
80
|
-
if [ -n "$old" ] && [ -n "$(printf %s "$old" | tr -d 0)" ]; then
|
|
81
|
-
git merge-base --is-ancestor "$old" "$new" 2>/dev/null || { echo "ak-roles: rejected non-fast-forward update of $ref (no amend/rebase/reset)" >&2; exit 1; }
|
|
82
|
-
fi
|
|
83
|
-
for commit in $(git rev-list "$new" --not --all 2>/dev/null); do
|
|
84
|
-
subj=$(git log -1 --format=%s "$commit")
|
|
85
|
-
case $subj in "$prefix"*) ;; *) echo "ak-roles: commit subject must start with $prefix (got: $subj)" >&2; exit 1 ;; esac
|
|
86
|
-
done
|
|
87
|
-
done
|
|
88
|
-
`;
|
|
67
|
+
function statusOf(error: unknown): unknown {
|
|
68
|
+
return typeof error === "object" && error !== null && "status" in error
|
|
69
|
+
? (error as { status: unknown }).status
|
|
70
|
+
: undefined;
|
|
71
|
+
}
|
|
89
72
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
* Never leave shared common config in a state that bricks sibling/main trees
|
|
93
|
-
* (git requires core.bare/core.worktree moved out of common when worktreeConfig is on).
|
|
94
|
-
*/
|
|
95
|
-
export function installWorkerGitHooks(cwd: string): void {
|
|
96
|
-
// Fail closed before any shared-config write: bare host / non-work-tree is not armable.
|
|
97
|
-
let inside: string;
|
|
73
|
+
function tryGetAll(file: string, key: string): string[] {
|
|
74
|
+
if (!existsSync(file)) return [];
|
|
98
75
|
try {
|
|
99
|
-
|
|
76
|
+
const out = gitFile(file, ["--get-all", key]);
|
|
77
|
+
return out.length === 0 ? [] : out.split("\n");
|
|
100
78
|
} catch (error) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}`,
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
if (inside !== "true") {
|
|
108
|
-
throw new Error("ak-roles: refusing worker hooks install outside a git work tree");
|
|
79
|
+
// --get-all exit 1 = absent; other failures stay loud.
|
|
80
|
+
if (statusOf(error) !== 1) throw error;
|
|
81
|
+
return [];
|
|
109
82
|
}
|
|
83
|
+
}
|
|
110
84
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
85
|
+
/** True only when the file exists and carries the historical package marker.
|
|
86
|
+
* Read failures propagate — never disguised as "not owned". */
|
|
87
|
+
function ownedHook(path: string): boolean {
|
|
88
|
+
if (!existsSync(path)) return false;
|
|
89
|
+
return readFileSync(path, "utf8").includes(HOOK_MARKER);
|
|
90
|
+
}
|
|
114
91
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
try { worktreeInCommon = gitFile(commonConfig, ["--get", "core.worktree"]); } catch { /* unset */ }
|
|
92
|
+
/** Escape a hooksPath value for git config --unset value-pattern (POSIX ERE). */
|
|
93
|
+
function escapeGitConfigValueRegex(value: string): string {
|
|
94
|
+
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
95
|
+
}
|
|
120
96
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Remove only package-owned core.hooksPath values; keep every foreign value.
|
|
99
|
+
* --unset without a value-pattern exits 5 for both "absent" and "multi-value",
|
|
100
|
+
* so multi-valued keys must be addressed per matching value.
|
|
101
|
+
*/
|
|
102
|
+
function unsetOwnedHooksPath(file: string): string[] {
|
|
103
|
+
const owned: string[] = [];
|
|
104
|
+
for (const value of tryGetAll(file, "core.hooksPath")) {
|
|
105
|
+
if (!ownedHook(resolve(value, HOOK_FILE))) continue;
|
|
106
|
+
try {
|
|
107
|
+
// --unset-all + exact value-pattern drops every duplicate owned copy; foreign stays.
|
|
108
|
+
gitFile(file, [
|
|
109
|
+
"--unset-all",
|
|
110
|
+
"core.hooksPath",
|
|
111
|
+
`^${escapeGitConfigValueRegex(value)}$`,
|
|
112
|
+
]);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
// 5 = this specific value already absent (not multi-value ambiguity).
|
|
115
|
+
if (statusOf(error) !== 5) throw error;
|
|
116
|
+
}
|
|
117
|
+
owned.push(value);
|
|
139
118
|
}
|
|
119
|
+
return owned;
|
|
120
|
+
}
|
|
140
121
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
122
|
+
/** Delete only the package-owned hook file; rmdir solely when empty. */
|
|
123
|
+
function rmOwnedDir(dir: string): void {
|
|
124
|
+
const hookPath = resolve(dir, HOOK_FILE);
|
|
125
|
+
if (!ownedHook(hookPath)) return;
|
|
126
|
+
rmSync(hookPath, { force: true });
|
|
127
|
+
if (existsSync(dir) && readdirSync(dir).length === 0) rmdirSync(dir);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function linkedGitDirs(commonDir: string): string[] {
|
|
131
|
+
const root = resolve(commonDir, "worktrees");
|
|
132
|
+
if (!existsSync(root)) return [];
|
|
133
|
+
// Enumeration/lstat failures propagate — never skip a linked admin dir silently.
|
|
134
|
+
// lstat does not follow: symlink entries are not directories and stay out of range.
|
|
135
|
+
return readdirSync(root)
|
|
136
|
+
.map((name) => resolve(root, name))
|
|
137
|
+
.filter((dir) => lstatSync(dir).isDirectory());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* ADR 0070 §4 — private one-shot uninstall on arm.
|
|
142
|
+
* Range: current repo + enumerable worktree admin dirs. Owned hooksPath/files only.
|
|
143
|
+
* Never rolls back extensions.worktreeConfig / migrated bare|worktree / foreign hooksPath.
|
|
144
|
+
*/
|
|
145
|
+
function uninstallPackageWorkerHooks(cwd: string): void {
|
|
146
|
+
let inside: string;
|
|
147
|
+
try {
|
|
148
|
+
inside = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
149
|
+
} catch {
|
|
150
|
+
return;
|
|
149
151
|
}
|
|
152
|
+
if (inside !== "true") return;
|
|
150
153
|
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
154
|
+
const commonDir = git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
155
|
+
// Unset owned hooksPath before deleting the marker file (ownership check needs it).
|
|
156
|
+
const clear = (configFile: string): void => {
|
|
157
|
+
for (const hooks of unsetOwnedHooksPath(configFile)) rmOwnedDir(hooks);
|
|
158
|
+
};
|
|
159
|
+
clear(resolve(commonDir, "config"));
|
|
160
|
+
clear(resolve(commonDir, "config.worktree"));
|
|
161
|
+
rmOwnedDir(resolve(commonDir, HOOKS_DIR));
|
|
162
|
+
const legacy = resolve(commonDir, "hooks", HOOK_FILE);
|
|
163
|
+
if (ownedHook(legacy)) rmSync(legacy, { force: true });
|
|
164
|
+
for (const gitDir of linkedGitDirs(commonDir)) {
|
|
165
|
+
clear(resolve(gitDir, "config.worktree"));
|
|
166
|
+
rmOwnedDir(resolve(gitDir, HOOKS_DIR));
|
|
161
167
|
}
|
|
162
|
-
mkdirSync(dir, { recursive: true });
|
|
163
|
-
writeFileSync(path, HOOK, "utf8");
|
|
164
|
-
chmodSync(path, 0o755);
|
|
165
|
-
git(cwd, ["config", "--worktree", "core.hooksPath", dir]);
|
|
166
168
|
}
|
|
167
169
|
|
|
168
170
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
169
171
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
170
172
|
}
|
|
171
173
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
function openGateRecord(cwd: string, parent?: WorkerSubmissionGateParent): SessionManager {
|
|
177
|
-
return createRecordSession({
|
|
178
|
-
cwd,
|
|
179
|
-
kind: WORKER_SUBMISSION_GATE_RECORD_KIND,
|
|
180
|
-
...(parent === undefined ? {} : { parent }),
|
|
181
|
-
});
|
|
174
|
+
function unfinishedReasonPresent(details?: unknown): boolean {
|
|
175
|
+
if (typeof details !== "object" || details === null) return false;
|
|
176
|
+
const reason = (details as { reason?: unknown }).reason;
|
|
177
|
+
return typeof reason === "string" && reason.trim().length > 0;
|
|
182
178
|
}
|
|
183
179
|
|
|
184
180
|
function readGateState(session: SessionManager): {
|
|
185
181
|
baseline: string | null | undefined;
|
|
186
182
|
reminded: boolean;
|
|
183
|
+
prefixReminded: boolean;
|
|
187
184
|
} {
|
|
188
185
|
let baseline: string | null | undefined;
|
|
189
186
|
let reminded = false;
|
|
187
|
+
let prefixReminded = false;
|
|
190
188
|
for (const entry of session.getEntries()) {
|
|
191
189
|
if (entry.type !== "custom") continue;
|
|
192
190
|
if (entry.customType === WORKER_COMMIT_BASELINE_ENTRY_TYPE) {
|
|
@@ -196,9 +194,44 @@ function readGateState(session: SessionManager): {
|
|
|
196
194
|
}
|
|
197
195
|
} else if (entry.customType === WORKER_COMMIT_REMINDER_BOUNCE_ENTRY_TYPE) {
|
|
198
196
|
reminded = true;
|
|
197
|
+
} else if (entry.customType === WORKER_PREFIX_REMINDER_BOUNCE_ENTRY_TYPE) {
|
|
198
|
+
prefixReminded = true;
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
|
-
return { baseline, reminded };
|
|
201
|
+
return { baseline, reminded, prefixReminded };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isAncestor(cwd: string, ancestor: string, descendant: string): boolean {
|
|
205
|
+
try {
|
|
206
|
+
git(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]);
|
|
207
|
+
return true;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (statusOf(error) === 1) return false;
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Reliable window (ADR 0070). null = unreliable tip-SHA baseline; [] = empty.
|
|
216
|
+
* Structured git-log fields only — never parse a shell command string.
|
|
217
|
+
*/
|
|
218
|
+
function reliableWindow(
|
|
219
|
+
cwd: string,
|
|
220
|
+
baseline: string | null,
|
|
221
|
+
head: string,
|
|
222
|
+
): ReadonlyArray<{ subject: string; merge: boolean }> | null {
|
|
223
|
+
if (baseline !== null && !isAncestor(cwd, baseline, head)) return null;
|
|
224
|
+
const range = baseline === null ? head : `${baseline}..${head}`;
|
|
225
|
+
const raw = git(cwd, ["log", "--format=%P%x1e%s", range]);
|
|
226
|
+
if (raw.length === 0) return [];
|
|
227
|
+
return raw.split("\n").flatMap((line) => {
|
|
228
|
+
const sep = line.indexOf("\x1e");
|
|
229
|
+
if (sep < 0) return [];
|
|
230
|
+
return [{
|
|
231
|
+
subject: line.slice(sep + 1),
|
|
232
|
+
merge: line.slice(0, sep).trim().includes(" "),
|
|
233
|
+
}];
|
|
234
|
+
});
|
|
202
235
|
}
|
|
203
236
|
|
|
204
237
|
export function createWorkerSubmissionGate(): {
|
|
@@ -208,37 +241,42 @@ export function createWorkerSubmissionGate(): {
|
|
|
208
241
|
let baseline: string | null | undefined;
|
|
209
242
|
let root: string | undefined;
|
|
210
243
|
let reminded = false;
|
|
244
|
+
let prefixReminded = false;
|
|
211
245
|
let unfinishedReasonBounces = 0;
|
|
212
246
|
let record: SessionManager | undefined;
|
|
213
|
-
// null = unborn HEAD only; any other git failure throws (no swallow).
|
|
214
247
|
const head = (cwd: string): string | null => {
|
|
215
|
-
try {
|
|
216
|
-
|
|
217
|
-
|
|
248
|
+
try {
|
|
249
|
+
return git(cwd, ["rev-parse", "HEAD"]);
|
|
250
|
+
} catch {
|
|
251
|
+
git(cwd, ["rev-parse", "--git-dir"]); // surface real git failures
|
|
218
252
|
return null;
|
|
219
253
|
}
|
|
220
254
|
};
|
|
221
255
|
return {
|
|
222
256
|
arm(cwd, parent) {
|
|
257
|
+
uninstallPackageWorkerHooks(cwd);
|
|
223
258
|
root = cwd;
|
|
224
|
-
record =
|
|
259
|
+
record = createRecordSession({
|
|
260
|
+
cwd,
|
|
261
|
+
kind: WORKER_SUBMISSION_GATE_RECORD_KIND,
|
|
262
|
+
...(parent === undefined ? {} : { parent }),
|
|
263
|
+
});
|
|
225
264
|
const prior = readGateState(record);
|
|
226
265
|
if (prior.baseline !== undefined) {
|
|
227
|
-
// Cross-resume: keep first-arm baseline and any prior bounce (no second false bounce).
|
|
228
266
|
baseline = prior.baseline;
|
|
229
267
|
reminded = prior.reminded;
|
|
268
|
+
prefixReminded = prior.prefixReminded;
|
|
230
269
|
return;
|
|
231
270
|
}
|
|
232
271
|
baseline = head(cwd);
|
|
233
272
|
reminded = false;
|
|
234
|
-
|
|
273
|
+
prefixReminded = false;
|
|
235
274
|
record.appendCustomEntry(WORKER_COMMIT_BASELINE_ENTRY_TYPE, {
|
|
236
275
|
version: 1,
|
|
237
276
|
head: baseline,
|
|
238
277
|
});
|
|
239
278
|
},
|
|
240
279
|
assertAcceptable(status, details) {
|
|
241
|
-
// #292: unfinished without a non-blank reason → in-session bounce (max 2), then accept.
|
|
242
280
|
if (status === "unfinished" && !unfinishedReasonPresent(details)) {
|
|
243
281
|
if (unfinishedReasonBounces < UNFINISHED_REASON_BOUNCE_LIMIT) {
|
|
244
282
|
unfinishedReasonBounces += 1;
|
|
@@ -247,14 +285,29 @@ export function createWorkerSubmissionGate(): {
|
|
|
247
285
|
}
|
|
248
286
|
if (baseline === undefined || root === undefined || !DONE.has(status)) return;
|
|
249
287
|
const now = head(root);
|
|
250
|
-
|
|
288
|
+
const headMoved = now !== null && (baseline === null || now !== baseline);
|
|
289
|
+
|
|
290
|
+
// Gate ① — forgetfulness reminder (ADR 0066; behavior unchanged).
|
|
291
|
+
if (!headMoved && !reminded) {
|
|
251
292
|
reminded = true;
|
|
252
|
-
|
|
293
|
+
record?.appendCustomEntry(WORKER_COMMIT_REMINDER_BOUNCE_ENTRY_TYPE, { version: 1 });
|
|
294
|
+
throw new WorkerCommitReminderError();
|
|
253
295
|
}
|
|
254
296
|
reminded = true;
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
297
|
+
|
|
298
|
+
// Gate ② — open platform-prefix soft reminder (ADR 0070).
|
|
299
|
+
if (prefixReminded || now === null) return;
|
|
300
|
+
const window = reliableWindow(root, baseline, now);
|
|
301
|
+
if (
|
|
302
|
+
window === null ||
|
|
303
|
+
window.length === 0 ||
|
|
304
|
+
!window.some((c) => !c.merge && !PLATFORM_PREFIX.test(c.subject))
|
|
305
|
+
) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
prefixReminded = true;
|
|
309
|
+
record?.appendCustomEntry(WORKER_PREFIX_REMINDER_BOUNCE_ENTRY_TYPE, { version: 1 });
|
|
310
|
+
throw new WorkerPrefixReminderError();
|
|
258
311
|
},
|
|
259
312
|
};
|
|
260
313
|
}
|