@nextclaw/x-harness 0.0.0-stage → 0.1.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/LICENSE +10 -0
- package/README.md +41 -2
- package/cli/main.js +207 -0
- package/dist/api.d.ts +10 -0
- package/dist/api.js +256 -0
- package/dist/boundary.d.ts +2 -0
- package/dist/browser-common.d.ts +4 -0
- package/dist/browser-common.js +29 -0
- package/dist/browser-dom.d.ts +2 -0
- package/dist/browser-dom.js +854 -0
- package/dist/browser-native.d.ts +1 -0
- package/dist/browser-native.js +211 -0
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +143 -0
- package/dist/creator.d.ts +29 -0
- package/dist/creator.js +164 -0
- package/dist/growth.d.ts +1 -0
- package/dist/growth.js +1918 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +22 -0
- package/dist/observation.d.ts +1 -0
- package/dist/observation.js +2 -0
- package/dist/platform.d.ts +2 -0
- package/dist/platform.js +681 -0
- package/dist/readers.d.ts +20 -0
- package/dist/readers.js +1045 -0
- package/dist/results.d.ts +6 -0
- package/dist/results.js +233 -0
- package/dist/round-state.d.ts +3 -0
- package/dist/round-state.js +155 -0
- package/dist/store.d.ts +30 -0
- package/dist/store.js +175 -0
- package/dist/types.d.ts +397 -0
- package/generated/observation.d.mts +1 -0
- package/generated/observation.mjs +514 -0
- package/package.json +29 -3
- package/skill/SKILL.md +52 -0
- package/skill/references/api.md +37 -0
package/dist/growth.js
ADDED
|
@@ -0,0 +1,1918 @@
|
|
|
1
|
+
import { checkRound } from "./round-state.js";
|
|
2
|
+
import { xURL } from "./platform.js";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
const kinds = ["follow", "original", "reply"];
|
|
5
|
+
const followWindows = [
|
|
6
|
+
[600000, 20],
|
|
7
|
+
[3600000, 100],
|
|
8
|
+
];
|
|
9
|
+
const operations = {
|
|
10
|
+
follow: "submitFollow",
|
|
11
|
+
original: "submitPost",
|
|
12
|
+
reply: "submitReply",
|
|
13
|
+
};
|
|
14
|
+
const same = (a, b) => typeof a === "string" &&
|
|
15
|
+
typeof b === "string" &&
|
|
16
|
+
a.toLowerCase() === b.toLowerCase();
|
|
17
|
+
// No UI implementation, hidden state, scheduling or LLM calls in this layer.
|
|
18
|
+
export function createGrowthHarness({ platform, store, clock = () => Date.now(), executor, } = {}) {
|
|
19
|
+
const stamp = () => new Date(clock()).toISOString();
|
|
20
|
+
const evidenceExists = store.evidenceExists ?? (() => false);
|
|
21
|
+
const runtimeId = randomUUID();
|
|
22
|
+
let claim, running = false;
|
|
23
|
+
const readCache = new Map();
|
|
24
|
+
if (executor &&
|
|
25
|
+
(!executor.id ||
|
|
26
|
+
!executor.tabId ||
|
|
27
|
+
!Array.isArray(executor.scopeKinds) ||
|
|
28
|
+
!executor.scopeKinds.length ||
|
|
29
|
+
new Set(executor.scopeKinds).size !== executor.scopeKinds.length ||
|
|
30
|
+
executor.scopeKinds.some((k) => !kinds.includes(k)) ||
|
|
31
|
+
!evidenceExists(executor.evidenceRef) ||
|
|
32
|
+
typeof store.transaction !== "function"))
|
|
33
|
+
throw new Error("并行执行者需ID、独立tab、有效职责及原合同证据与transaction");
|
|
34
|
+
const scope = (state) => executor?.scopeKinds ?? state.scopeKinds ?? kinds;
|
|
35
|
+
const project = (state) => executor
|
|
36
|
+
? {
|
|
37
|
+
...state,
|
|
38
|
+
scopeKinds: executor.scopeKinds,
|
|
39
|
+
scopeEvidenceRef: executor.evidenceRef,
|
|
40
|
+
status: state.executions?.[executor.id]?.status ?? "RUNNING",
|
|
41
|
+
stop: state.executions?.[executor.id]?.stop,
|
|
42
|
+
}
|
|
43
|
+
: state;
|
|
44
|
+
async function load(taskId) {
|
|
45
|
+
const snapshot = await store.load();
|
|
46
|
+
if (snapshot.state.batchId !== taskId)
|
|
47
|
+
throw new Error("任务ID不是原台账当前批次");
|
|
48
|
+
// Expired WAITING is actionable, not a malformed ledger. Validate structure separately from stopping.
|
|
49
|
+
const gate = checkRound({ ...project(snapshot.state), status: "RUNNING" }, { now: clock(), evidenceExists });
|
|
50
|
+
if (!gate.valid)
|
|
51
|
+
throw new Error(`原轮次标记无效:${gate.errors.join(";")}`);
|
|
52
|
+
const actions = snapshot.state.actions ?? [];
|
|
53
|
+
if (new Set(actions.map((a) => a.id)).size !== actions.length)
|
|
54
|
+
throw new Error("原队列动作ID重复");
|
|
55
|
+
for (const a of actions) {
|
|
56
|
+
if (!a.id ||
|
|
57
|
+
!kinds.includes(a.kind) ||
|
|
58
|
+
![
|
|
59
|
+
"prepared",
|
|
60
|
+
"intent",
|
|
61
|
+
"unknown",
|
|
62
|
+
"verified",
|
|
63
|
+
"preexisting",
|
|
64
|
+
"skipped",
|
|
65
|
+
].includes(a.status))
|
|
66
|
+
throw new Error("原队列动作结构无效");
|
|
67
|
+
}
|
|
68
|
+
return snapshot;
|
|
69
|
+
}
|
|
70
|
+
async function save(snapshot, state) {
|
|
71
|
+
state.updatedAt = stamp();
|
|
72
|
+
if (!executor)
|
|
73
|
+
return store.save(snapshot, state);
|
|
74
|
+
// Batch metadata still uses a short withLock transaction; it never contains browser I/O.
|
|
75
|
+
if (!claim)
|
|
76
|
+
return store.save(snapshot, state);
|
|
77
|
+
return store.transaction((latest) => {
|
|
78
|
+
const next = structuredClone(latest.state), before = snapshot.state.actions.find((a) => a.id === claim.actionId);
|
|
79
|
+
const entry = next.actions.find((a) => a.id === claim.actionId), after = state.actions.find((a) => a.id === claim.actionId);
|
|
80
|
+
if (next.batchId !== snapshot.state.batchId ||
|
|
81
|
+
entry?.claim?.token !== claim.token ||
|
|
82
|
+
JSON.stringify({ ...entry, claim: undefined }) !==
|
|
83
|
+
JSON.stringify({ ...before, claim: undefined }))
|
|
84
|
+
throw new Error("action-claim-or-version-conflict");
|
|
85
|
+
const oldItem = snapshot.state.items.find((i) => i.kind === entry.kind), newItem = state.items.find((i) => i.kind === entry.kind);
|
|
86
|
+
const item = next.items.find((i) => i.kind === entry.kind);
|
|
87
|
+
const reservation = before.status === "prepared" && after.status === "intent";
|
|
88
|
+
if (reservation) {
|
|
89
|
+
if (!item.actionable || item.verified + item.unknown >= item.planned)
|
|
90
|
+
throw new Error("shared-target-capacity-exhausted");
|
|
91
|
+
if (entry.kind === "follow" && waitBudget(next))
|
|
92
|
+
throw new Error("shared-follow-budget-exhausted");
|
|
93
|
+
}
|
|
94
|
+
if (after.status === "verified" &&
|
|
95
|
+
entry.kind !== "follow" &&
|
|
96
|
+
next.actions.some((a) => a.id !== entry.id &&
|
|
97
|
+
a.status === "verified" &&
|
|
98
|
+
a.publicId === after.publicId))
|
|
99
|
+
throw new Error("同一公开结果不能重复结算");
|
|
100
|
+
Object.assign(entry, after, { claim: entry.claim });
|
|
101
|
+
item.verified += newItem.verified - oldItem.verified;
|
|
102
|
+
item.unknown += newItem.unknown - oldItem.unknown;
|
|
103
|
+
if (item.unknown < 0 || item.verified + item.unknown > item.planned)
|
|
104
|
+
throw new Error("并行结算越界");
|
|
105
|
+
item.resultEvidence = [
|
|
106
|
+
...new Set([
|
|
107
|
+
...(item.resultEvidence ?? []),
|
|
108
|
+
...(newItem.resultEvidence ?? []),
|
|
109
|
+
]),
|
|
110
|
+
];
|
|
111
|
+
for (const key of [
|
|
112
|
+
"actionable",
|
|
113
|
+
"reason",
|
|
114
|
+
"resumeAt",
|
|
115
|
+
"resumeWhen",
|
|
116
|
+
"blockEvidence",
|
|
117
|
+
]) {
|
|
118
|
+
if (JSON.stringify(oldItem[key]) !== JSON.stringify(newItem[key])) {
|
|
119
|
+
if (key in newItem)
|
|
120
|
+
item[key] = newItem[key];
|
|
121
|
+
else
|
|
122
|
+
delete item[key];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (reservation) {
|
|
126
|
+
const attempt = state.attempts.at(-1);
|
|
127
|
+
next.attempts ??= [];
|
|
128
|
+
next.attempts.push(attempt);
|
|
129
|
+
if (entry.kind === "follow") {
|
|
130
|
+
next.execution ??= {};
|
|
131
|
+
const old = next.execution.followGroup;
|
|
132
|
+
next.execution.followGroup = {
|
|
133
|
+
attempts: old?.attempts < 10 && clock() < Date.parse(old.lastAt) + 180000
|
|
134
|
+
? old.attempts + 1
|
|
135
|
+
: 1,
|
|
136
|
+
lastAt: attempt.at,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
next.status = "RUNNING";
|
|
141
|
+
next.updatedAt = stamp();
|
|
142
|
+
return next;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function result(snapshot, needs = []) {
|
|
146
|
+
const gate = checkRound(project(snapshot.state), {
|
|
147
|
+
now: clock(),
|
|
148
|
+
evidenceExists,
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
status: gate.mayEnd
|
|
152
|
+
? gate.status === "COMPLETE"
|
|
153
|
+
? "complete"
|
|
154
|
+
: "waiting"
|
|
155
|
+
: needs.some((n) => n.status === "needsCapability")
|
|
156
|
+
? "needsCapability"
|
|
157
|
+
: "needsDecision",
|
|
158
|
+
taskId: snapshot.state.batchId,
|
|
159
|
+
mayEnd: gate.mayEnd,
|
|
160
|
+
scopeKinds: gate.scopeKinds,
|
|
161
|
+
actual: Object.fromEntries(snapshot.state.items.map((i) => [
|
|
162
|
+
i.kind,
|
|
163
|
+
{ verified: i.verified, unknown: i.unknown },
|
|
164
|
+
])),
|
|
165
|
+
gaps: gate.pending,
|
|
166
|
+
...(needs.length ? { decision: needs } : {}),
|
|
167
|
+
evidenceRef: snapshot.path ?? store.path,
|
|
168
|
+
...(gate.errors.length ? { settlementErrors: gate.errors } : {}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function capability(operation, input = {}) {
|
|
172
|
+
const kind = input.kind ??
|
|
173
|
+
{
|
|
174
|
+
followOne: "follow",
|
|
175
|
+
followBatch: "follow",
|
|
176
|
+
submitFollow: "follow",
|
|
177
|
+
publishPost: "original",
|
|
178
|
+
submitPost: "original",
|
|
179
|
+
replyToPost: "reply",
|
|
180
|
+
submitReply: "reply",
|
|
181
|
+
}[operation];
|
|
182
|
+
const required = [
|
|
183
|
+
"preflight",
|
|
184
|
+
"readAccount",
|
|
185
|
+
"findSubmission",
|
|
186
|
+
"captureProof",
|
|
187
|
+
...(operation === "read" ? [] : [operations[kind]]),
|
|
188
|
+
];
|
|
189
|
+
if (kind === "follow")
|
|
190
|
+
required.push("readPerson");
|
|
191
|
+
const missing = required.filter((name) => typeof platform?.[name] !== "function");
|
|
192
|
+
const storage = ["load", "save", "withLock", "recordProof"].filter((name) => typeof store?.[name] !== "function");
|
|
193
|
+
if (operation === "followBatch" && typeof store?.transaction !== "function")
|
|
194
|
+
storage.push("transaction");
|
|
195
|
+
if (kind === "original" &&
|
|
196
|
+
operation !== "read" &&
|
|
197
|
+
typeof store?.fingerprint !== "function")
|
|
198
|
+
storage.push("fingerprint");
|
|
199
|
+
const lower = typeof platform?.preflight === "function"
|
|
200
|
+
? platform.preflight({
|
|
201
|
+
operation: operation === "read" ? "read" : operations[kind],
|
|
202
|
+
input,
|
|
203
|
+
})
|
|
204
|
+
: { covered: false, missing: [] };
|
|
205
|
+
return {
|
|
206
|
+
covered: !missing.length && !storage.length && lower.covered,
|
|
207
|
+
missing: [
|
|
208
|
+
...missing.map((n) => `platform.${n}`),
|
|
209
|
+
...storage.map((n) => `store.${n}`),
|
|
210
|
+
...(lower.missing ?? []),
|
|
211
|
+
],
|
|
212
|
+
owner: missing.length
|
|
213
|
+
? "scripts/x-harness-platform.mjs"
|
|
214
|
+
: storage.length
|
|
215
|
+
? "scripts/x-harness-store.mjs"
|
|
216
|
+
: lower.owner,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function waitBudget(state) {
|
|
220
|
+
const now = clock();
|
|
221
|
+
const hour = Number(new Intl.DateTimeFormat("en-GB", {
|
|
222
|
+
timeZone: "Asia/Shanghai",
|
|
223
|
+
hour: "2-digit",
|
|
224
|
+
hourCycle: "h23",
|
|
225
|
+
}).format(now));
|
|
226
|
+
if (hour < 7 || hour >= 22)
|
|
227
|
+
return {
|
|
228
|
+
status: "needsDecision",
|
|
229
|
+
reason: "outside-contract-write-window",
|
|
230
|
+
};
|
|
231
|
+
const coverage = state.attemptsCoverage;
|
|
232
|
+
if (!coverage?.complete ||
|
|
233
|
+
!Number.isFinite(Date.parse(coverage.since)) ||
|
|
234
|
+
Date.parse(coverage.since) > now - 3600000 ||
|
|
235
|
+
!evidenceExists(coverage.evidenceRef))
|
|
236
|
+
return {
|
|
237
|
+
status: "needsDecision",
|
|
238
|
+
reason: "shared-attempt-history-incomplete",
|
|
239
|
+
};
|
|
240
|
+
const attempts = state.attempts ?? [];
|
|
241
|
+
if (attempts.some((a) => !kinds.includes(a.kind) || !Number.isFinite(Date.parse(a.at))))
|
|
242
|
+
return { status: "needsDecision", reason: "attempt-history-invalid" };
|
|
243
|
+
const follows = attempts.filter((a) => a.kind === "follow");
|
|
244
|
+
for (const [duration, limit] of followWindows) {
|
|
245
|
+
const recent = follows
|
|
246
|
+
.filter((a) => Date.parse(a.at) > now - duration)
|
|
247
|
+
.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
|
248
|
+
if (recent.length >= limit)
|
|
249
|
+
return {
|
|
250
|
+
status: "waiting",
|
|
251
|
+
reason: "follow-rolling-budget",
|
|
252
|
+
resumeAt: new Date(Date.parse(recent[recent.length - limit].at) + duration).toISOString(),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const group = state.execution?.followGroup;
|
|
256
|
+
if (group?.attempts >= 10 && now < Date.parse(group.lastAt) + 180000)
|
|
257
|
+
return {
|
|
258
|
+
status: "waiting",
|
|
259
|
+
reason: "follow-group-cooldown",
|
|
260
|
+
resumeAt: new Date(Date.parse(group.lastAt) + 180000).toISOString(),
|
|
261
|
+
};
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
function guardInput(state, action, independentExecution = undefined) {
|
|
265
|
+
if (!independentExecution && !scope(state).includes(action.kind))
|
|
266
|
+
return "kind-outside-round-scope";
|
|
267
|
+
if (independentExecution) {
|
|
268
|
+
if (action.kind !== "original" ||
|
|
269
|
+
!independentExecution.tabId ||
|
|
270
|
+
!evidenceExists(independentExecution.evidenceRef) ||
|
|
271
|
+
!evidenceExists(independentExecution.scopeEvidenceRef))
|
|
272
|
+
return "independent-execution-proof-missing";
|
|
273
|
+
}
|
|
274
|
+
else if (!executor &&
|
|
275
|
+
(!state.execution?.exclusive || state.execution.owner !== "harness"))
|
|
276
|
+
return "exclusive-account-execution-unconfirmed";
|
|
277
|
+
if (!same(state.accountHandle, action.accountHandle))
|
|
278
|
+
return "account-identity-mismatch";
|
|
279
|
+
if (!evidenceExists(action.accountEvidenceRef) ||
|
|
280
|
+
xURL(action.accountURL).pathname.toLowerCase() !==
|
|
281
|
+
`/${state.accountHandle}`.toLowerCase())
|
|
282
|
+
return "own-profile-proof-missing";
|
|
283
|
+
if (!action.quality?.approved ||
|
|
284
|
+
!evidenceExists(action.quality.evidenceRef))
|
|
285
|
+
return "quality-review-required";
|
|
286
|
+
if (action.kind === "follow") {
|
|
287
|
+
if (!same(action.quality.handle, action.handle) ||
|
|
288
|
+
action.quality.blue !== true ||
|
|
289
|
+
action.quality.chinese !== true)
|
|
290
|
+
return "follow-qualification-required";
|
|
291
|
+
if (!evidenceExists(action.evidenceRef))
|
|
292
|
+
return "person-observation-proof-missing";
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
if (!action.text || action.quality.approvedText !== action.text)
|
|
296
|
+
return "exact-text-quality-review-required";
|
|
297
|
+
if (action.kind === "original" &&
|
|
298
|
+
(!action.draftPath || !evidenceExists(action.draftPath)))
|
|
299
|
+
return "draft-source-missing";
|
|
300
|
+
if (action.kind === "original" &&
|
|
301
|
+
(!action.draftPath.endsWith(".post.md") ||
|
|
302
|
+
action.quality.draftFingerprint !==
|
|
303
|
+
store.fingerprint(action.draftPath)))
|
|
304
|
+
return "draft-changed-since-quality-review";
|
|
305
|
+
if (action.kind === "original" &&
|
|
306
|
+
(!evidenceExists(action.composeEvidenceRef) ||
|
|
307
|
+
xURL(action.composeURL).pathname !== "/compose/post"))
|
|
308
|
+
return "observed-composer-proof-missing";
|
|
309
|
+
if (action.kind === "reply" &&
|
|
310
|
+
(!/^\d+$/.test(action.postId ?? "") ||
|
|
311
|
+
!evidenceExists(action.evidenceRef)))
|
|
312
|
+
return "reply-target-proof-missing";
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
async function settleProof(snapshot, action, observation, { preserveStatus = false } = {}) {
|
|
317
|
+
const proof = await store.recordProof(await platform.captureProof({ observation }));
|
|
318
|
+
const state = structuredClone(snapshot.state);
|
|
319
|
+
const entry = state.actions.find((a) => a.id === action.id);
|
|
320
|
+
const item = state.items.find((i) => i.kind === action.kind);
|
|
321
|
+
if (observation.verified) {
|
|
322
|
+
if (action.kind !== "follow" &&
|
|
323
|
+
(!/^\d+$/.test(observation.publicId ?? "") ||
|
|
324
|
+
state.actions.some((a) => a.id !== action.id &&
|
|
325
|
+
a.status === "verified" &&
|
|
326
|
+
a.publicId === observation.publicId)))
|
|
327
|
+
throw new Error("同一公开结果不能重复结算");
|
|
328
|
+
if (["intent", "unknown"].includes(entry.status))
|
|
329
|
+
item.unknown -= 1;
|
|
330
|
+
if (entry.status !== "verified")
|
|
331
|
+
item.verified += 1;
|
|
332
|
+
entry.status = "verified";
|
|
333
|
+
entry.publicId = observation.publicId;
|
|
334
|
+
entry.resultEvidence = proof;
|
|
335
|
+
item.resultEvidence = [
|
|
336
|
+
...new Set([...(item.resultEvidence ?? []), proof]),
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
if (!["intent", "unknown"].includes(entry.status))
|
|
341
|
+
item.unknown += 1;
|
|
342
|
+
entry.status = "unknown";
|
|
343
|
+
entry.resultEvidence = proof;
|
|
344
|
+
}
|
|
345
|
+
if (item.verified > item.planned || item.unknown < 0)
|
|
346
|
+
throw new Error("结算越界:不覆盖原目标或重复计数");
|
|
347
|
+
if (!preserveStatus)
|
|
348
|
+
state.status = "RUNNING";
|
|
349
|
+
return save(snapshot, state);
|
|
350
|
+
}
|
|
351
|
+
async function execute(snapshot, action) {
|
|
352
|
+
const recovering = ["intent", "unknown"].includes(action.status);
|
|
353
|
+
const caps = capability(recovering ? "read" : operations[action.kind], action);
|
|
354
|
+
if (!caps.covered)
|
|
355
|
+
return {
|
|
356
|
+
snapshot,
|
|
357
|
+
need: { status: "needsCapability", actionId: action.id, ...caps },
|
|
358
|
+
};
|
|
359
|
+
// Recovery is read-only even outside the write window. Never retry an intent.
|
|
360
|
+
if (recovering) {
|
|
361
|
+
let observation;
|
|
362
|
+
try {
|
|
363
|
+
observation = await platform.findSubmission(action);
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
observation = {
|
|
367
|
+
verified: false,
|
|
368
|
+
missing: ["dedup-read-failed"],
|
|
369
|
+
error: error.message.slice(0, 240),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
snapshot = await settleProof(snapshot, action, observation);
|
|
373
|
+
return {
|
|
374
|
+
snapshot,
|
|
375
|
+
need: observation.verified
|
|
376
|
+
? null
|
|
377
|
+
: {
|
|
378
|
+
status: "needsDecision",
|
|
379
|
+
actionId: action.id,
|
|
380
|
+
reason: "submission-unknown-do-not-resubmit",
|
|
381
|
+
accountAlert: Boolean(observation.alerts?.length),
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
if (snapshot.state.items
|
|
386
|
+
.find((i) => i.kind === action.kind)
|
|
387
|
+
.reason?.startsWith("harness:") &&
|
|
388
|
+
Date.parse(snapshot.state.items.find((i) => i.kind === action.kind).resumeAt) <= clock()) {
|
|
389
|
+
const next = structuredClone(snapshot.state), pending = next.items.find((i) => i.kind === action.kind);
|
|
390
|
+
pending.actionable = true;
|
|
391
|
+
delete pending.reason;
|
|
392
|
+
delete pending.resumeAt;
|
|
393
|
+
delete pending.resumeWhen;
|
|
394
|
+
delete pending.blockEvidence;
|
|
395
|
+
next.status = "RUNNING";
|
|
396
|
+
snapshot = await save(snapshot, next);
|
|
397
|
+
}
|
|
398
|
+
const state = snapshot.state;
|
|
399
|
+
const item = state.items.find((i) => i.kind === action.kind);
|
|
400
|
+
if (!item.actionable)
|
|
401
|
+
return {
|
|
402
|
+
snapshot,
|
|
403
|
+
need: {
|
|
404
|
+
status: "needsDecision",
|
|
405
|
+
actionId: action.id,
|
|
406
|
+
reason: item.reason ?? "original-kind-not-actionable",
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
const inputError = guardInput(state, action);
|
|
410
|
+
if (inputError)
|
|
411
|
+
return {
|
|
412
|
+
snapshot,
|
|
413
|
+
need: {
|
|
414
|
+
status: "needsDecision",
|
|
415
|
+
actionId: action.id,
|
|
416
|
+
reason: inputError,
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
let own;
|
|
420
|
+
try {
|
|
421
|
+
own = await platform.readAccount();
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
return {
|
|
425
|
+
snapshot,
|
|
426
|
+
need: {
|
|
427
|
+
status: "needsDecision",
|
|
428
|
+
actionId: action.id,
|
|
429
|
+
reason: "account-read-failed",
|
|
430
|
+
error: error.message.slice(0, 240),
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
if (own.needsStateCheck ||
|
|
435
|
+
own.alerts?.length ||
|
|
436
|
+
!same(own.accountHandle, state.accountHandle))
|
|
437
|
+
return {
|
|
438
|
+
snapshot,
|
|
439
|
+
need: {
|
|
440
|
+
status: "needsDecision",
|
|
441
|
+
actionId: action.id,
|
|
442
|
+
reason: "fresh-account-unconfirmed",
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
if (action.kind === "follow") {
|
|
446
|
+
const budget = waitBudget(state);
|
|
447
|
+
if (budget)
|
|
448
|
+
return { snapshot, need: { ...budget, actionId: action.id } };
|
|
449
|
+
let value;
|
|
450
|
+
try {
|
|
451
|
+
value = await platform.readPerson(action);
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
return {
|
|
455
|
+
snapshot,
|
|
456
|
+
need: {
|
|
457
|
+
status: "needsDecision",
|
|
458
|
+
actionId: action.id,
|
|
459
|
+
reason: "person-read-failed",
|
|
460
|
+
error: error.message.slice(0, 240),
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
if (value.needsStateCheck ||
|
|
465
|
+
value.alerts?.length ||
|
|
466
|
+
value.relationship === "unknown" ||
|
|
467
|
+
!same(value.accountHandle, state.accountHandle))
|
|
468
|
+
return {
|
|
469
|
+
snapshot,
|
|
470
|
+
need: {
|
|
471
|
+
status: "needsDecision",
|
|
472
|
+
actionId: action.id,
|
|
473
|
+
reason: "fresh-person-unconfirmed",
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
if (value.blue === false)
|
|
477
|
+
return {
|
|
478
|
+
snapshot,
|
|
479
|
+
need: {
|
|
480
|
+
status: "needsDecision",
|
|
481
|
+
actionId: action.id,
|
|
482
|
+
reason: "fresh-blue-qualification-contradicts-review",
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
if (value.relationship === "following") {
|
|
486
|
+
const next = structuredClone(state);
|
|
487
|
+
next.actions.find((a) => a.id === action.id).status =
|
|
488
|
+
"preexisting";
|
|
489
|
+
return { snapshot: await save(snapshot, next), need: null };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
const hour = Number(new Intl.DateTimeFormat("en-GB", {
|
|
494
|
+
timeZone: "Asia/Shanghai",
|
|
495
|
+
hour: "2-digit",
|
|
496
|
+
hourCycle: "h23",
|
|
497
|
+
}).format(clock()));
|
|
498
|
+
if (hour < 7 || hour >= 22)
|
|
499
|
+
return {
|
|
500
|
+
snapshot,
|
|
501
|
+
need: {
|
|
502
|
+
status: "needsDecision",
|
|
503
|
+
actionId: action.id,
|
|
504
|
+
reason: "outside-contract-write-window",
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
let beforeIds = [];
|
|
509
|
+
if (action.kind !== "follow") {
|
|
510
|
+
let baseline;
|
|
511
|
+
try {
|
|
512
|
+
baseline = await platform.findSubmission({
|
|
513
|
+
...action,
|
|
514
|
+
intentAt: stamp(),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
return {
|
|
519
|
+
snapshot,
|
|
520
|
+
need: {
|
|
521
|
+
status: "needsDecision",
|
|
522
|
+
actionId: action.id,
|
|
523
|
+
reason: "publication-baseline-read-failed",
|
|
524
|
+
error: error.message.slice(0, 240),
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
if (baseline.needsStateCheck || baseline.alerts?.length)
|
|
529
|
+
return {
|
|
530
|
+
snapshot,
|
|
531
|
+
need: {
|
|
532
|
+
status: "needsDecision",
|
|
533
|
+
actionId: action.id,
|
|
534
|
+
reason: "publication-baseline-unconfirmed",
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
beforeIds = (baseline.posts ?? []).map((p) => p.id).filter(Boolean);
|
|
538
|
+
}
|
|
539
|
+
// Persist intent BEFORE any externally visible submission. Even a failed attempt consumes budget.
|
|
540
|
+
const next = structuredClone(state);
|
|
541
|
+
const entry = next.actions.find((a) => a.id === action.id);
|
|
542
|
+
entry.status = "intent";
|
|
543
|
+
entry.intentAt = stamp();
|
|
544
|
+
entry.beforeIds = beforeIds;
|
|
545
|
+
next.items.find((i) => i.kind === action.kind).unknown += 1;
|
|
546
|
+
next.attempts ??= [];
|
|
547
|
+
next.attempts.push({
|
|
548
|
+
actionId: action.id,
|
|
549
|
+
kind: action.kind,
|
|
550
|
+
at: entry.intentAt,
|
|
551
|
+
});
|
|
552
|
+
if (action.kind === "follow") {
|
|
553
|
+
next.execution ??= {};
|
|
554
|
+
const old = next.execution.followGroup;
|
|
555
|
+
next.execution.followGroup = {
|
|
556
|
+
attempts: old?.attempts < 10 && clock() < Date.parse(old.lastAt) + 180000
|
|
557
|
+
? old.attempts + 1
|
|
558
|
+
: 1,
|
|
559
|
+
lastAt: entry.intentAt,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
snapshot = await save(snapshot, next);
|
|
563
|
+
let observation, submissionMayHaveStarted = false;
|
|
564
|
+
try {
|
|
565
|
+
await platform[operations[action.kind]]({
|
|
566
|
+
...entry,
|
|
567
|
+
beforeSubmit: async () => {
|
|
568
|
+
const latest = await store.load();
|
|
569
|
+
if (executor) {
|
|
570
|
+
const current = latest.state.actions.find((a) => a.id === entry.id);
|
|
571
|
+
if (latest.state.batchId !== snapshot.state.batchId ||
|
|
572
|
+
current?.claim?.token !== claim.token ||
|
|
573
|
+
JSON.stringify(current) !==
|
|
574
|
+
JSON.stringify(snapshot.state.actions.find((a) => a.id === entry.id)))
|
|
575
|
+
throw new Error("提交前本动作或claim变化");
|
|
576
|
+
}
|
|
577
|
+
else if (latest.revision !== snapshot.revision)
|
|
578
|
+
throw new Error("提交前原台账变化,保留intent并重核执行权");
|
|
579
|
+
const inputError = guardInput(latest.state, latest.state.actions.find((a) => a.id === entry.id));
|
|
580
|
+
if (inputError)
|
|
581
|
+
throw new Error(inputError);
|
|
582
|
+
const hour = Number(new Intl.DateTimeFormat("en-GB", {
|
|
583
|
+
timeZone: "Asia/Shanghai",
|
|
584
|
+
hour: "2-digit",
|
|
585
|
+
hourCycle: "h23",
|
|
586
|
+
}).format(clock()));
|
|
587
|
+
if (hour < 7 || hour >= 22)
|
|
588
|
+
throw new Error("实际提交时已越过运营窗口");
|
|
589
|
+
if (action.kind === "follow") {
|
|
590
|
+
const check = structuredClone(latest.state);
|
|
591
|
+
check.attempts = check.attempts.filter((a) => a.actionId !== entry.id);
|
|
592
|
+
if (check.execution.followGroup)
|
|
593
|
+
check.execution.followGroup.attempts -= 1;
|
|
594
|
+
if (waitBudget(check))
|
|
595
|
+
throw new Error("实际提交前关注预算不再可用");
|
|
596
|
+
}
|
|
597
|
+
submissionMayHaveStarted = true;
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
observation = await platform.findSubmission(entry);
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
if (!submissionMayHaveStarted) {
|
|
604
|
+
const proof = await store.recordProof({
|
|
605
|
+
capturedAt: stamp(),
|
|
606
|
+
submissionStarted: false,
|
|
607
|
+
error: error.message.slice(0, 240),
|
|
608
|
+
});
|
|
609
|
+
const unsubmitted = structuredClone(snapshot.state), target = unsubmitted.actions.find((a) => a.id === entry.id);
|
|
610
|
+
target.status = "prepared";
|
|
611
|
+
target.preparationEvidence = proof;
|
|
612
|
+
unsubmitted.items.find((i) => i.kind === action.kind).unknown -= 1;
|
|
613
|
+
snapshot = await save(snapshot, unsubmitted);
|
|
614
|
+
return {
|
|
615
|
+
snapshot,
|
|
616
|
+
need: {
|
|
617
|
+
status: "needsDecision",
|
|
618
|
+
actionId: action.id,
|
|
619
|
+
reason: "pre-submit-preparation-failed",
|
|
620
|
+
evidenceRef: proof,
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
// A transport error does not distinguish a rejected write from a successful write.
|
|
625
|
+
observation = {
|
|
626
|
+
verified: false,
|
|
627
|
+
missing: ["submission-or-verification-failed"],
|
|
628
|
+
error: error.message.slice(0, 240),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
snapshot = await settleProof(snapshot, entry, observation);
|
|
632
|
+
return {
|
|
633
|
+
snapshot,
|
|
634
|
+
need: observation.verified
|
|
635
|
+
? null
|
|
636
|
+
: {
|
|
637
|
+
status: "needsDecision",
|
|
638
|
+
actionId: action.id,
|
|
639
|
+
reason: "submission-unknown-do-not-resubmit",
|
|
640
|
+
accountAlert: Boolean(observation.alerts?.length),
|
|
641
|
+
},
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
async function parallelRound({ taskId, decision, selector, requestedKind, maxActions = 10, } = {}) {
|
|
645
|
+
if (running)
|
|
646
|
+
return {
|
|
647
|
+
status: "needsDecision",
|
|
648
|
+
mayEnd: false,
|
|
649
|
+
taskId,
|
|
650
|
+
decision: [{ reason: "same-tab-operation-running" }],
|
|
651
|
+
};
|
|
652
|
+
running = true;
|
|
653
|
+
let completed = 0;
|
|
654
|
+
const needs = [];
|
|
655
|
+
try {
|
|
656
|
+
if (!Number.isInteger(maxActions) || maxActions < 1 || maxActions > 100)
|
|
657
|
+
throw new Error("maxActions须为1–100");
|
|
658
|
+
let snapshot = await load(taskId);
|
|
659
|
+
const activeKinds = scope(snapshot.state);
|
|
660
|
+
if (requestedKind && !activeKinds.includes(requestedKind))
|
|
661
|
+
return {
|
|
662
|
+
...result(snapshot),
|
|
663
|
+
status: "needsDecision",
|
|
664
|
+
decision: [{ reason: "kind-outside-round-scope" }],
|
|
665
|
+
};
|
|
666
|
+
snapshot = await store.transaction((latest) => {
|
|
667
|
+
if (latest.state.batchId !== taskId)
|
|
668
|
+
throw new Error("original-task-changed");
|
|
669
|
+
const next = structuredClone(latest.state);
|
|
670
|
+
next.executions ??= {};
|
|
671
|
+
if (Object.values(next.executions).some((e) => (e.tabId === executor.tabId || e.id === executor.id) &&
|
|
672
|
+
e.runtimeId !== runtimeId &&
|
|
673
|
+
(e.activity || e.actionId) &&
|
|
674
|
+
store.ownerAlive(e.pid)))
|
|
675
|
+
throw new Error("same-tab-or-executor-claimed");
|
|
676
|
+
next.executions[executor.id] = {
|
|
677
|
+
...next.executions[executor.id],
|
|
678
|
+
...executor,
|
|
679
|
+
runtimeId,
|
|
680
|
+
pid: store.processId,
|
|
681
|
+
activity: "round",
|
|
682
|
+
status: "RUNNING",
|
|
683
|
+
updatedAt: stamp(),
|
|
684
|
+
};
|
|
685
|
+
if (decision?.type === "prepareActions") {
|
|
686
|
+
if (!Array.isArray(decision.actions) ||
|
|
687
|
+
!same(next.accountHandle, decision.accountHandle) ||
|
|
688
|
+
!evidenceExists(decision.ownershipEvidenceRef))
|
|
689
|
+
throw new Error("需原账号核验和完整动作队列");
|
|
690
|
+
next.actions ??= [];
|
|
691
|
+
for (const action of decision.actions) {
|
|
692
|
+
if (!action.id ||
|
|
693
|
+
!activeKinds.includes(action.kind) ||
|
|
694
|
+
action.status !== "prepared" ||
|
|
695
|
+
next.actions.some((a) => a.id === action.id))
|
|
696
|
+
throw new Error("原prepared动作ID无效");
|
|
697
|
+
next.actions.push(structuredClone(action));
|
|
698
|
+
}
|
|
699
|
+
if (next.items.some((i) => next.actions.filter((a) => a.kind === i.kind && a.status === "prepared").length >
|
|
700
|
+
i.planned - i.verified - i.unknown))
|
|
701
|
+
throw new Error("队列超过原目标");
|
|
702
|
+
if (decision.sharedAttempts) {
|
|
703
|
+
if (!Array.isArray(decision.sharedAttempts))
|
|
704
|
+
throw new Error("共享尝试须为数组");
|
|
705
|
+
next.attempts = [
|
|
706
|
+
...(next.attempts ?? []),
|
|
707
|
+
...structuredClone(decision.sharedAttempts),
|
|
708
|
+
];
|
|
709
|
+
}
|
|
710
|
+
if (decision.attemptsCoverage) {
|
|
711
|
+
if (!decision.attemptsCoverage.complete ||
|
|
712
|
+
!evidenceExists(decision.attemptsCoverage.evidenceRef))
|
|
713
|
+
throw new Error("共享历史缺证据");
|
|
714
|
+
next.attemptsCoverage = structuredClone(decision.attemptsCoverage);
|
|
715
|
+
}
|
|
716
|
+
if (decision.nextScheduledCheckAt)
|
|
717
|
+
next.executions[executor.id].nextScheduledCheckAt =
|
|
718
|
+
decision.nextScheduledCheckAt;
|
|
719
|
+
}
|
|
720
|
+
else if (decision) {
|
|
721
|
+
const action = next.actions?.find((a) => a.id === decision.actionId);
|
|
722
|
+
if (!action ||
|
|
723
|
+
!activeKinds.includes(action.kind) ||
|
|
724
|
+
action.status !== "prepared" ||
|
|
725
|
+
action.claim ||
|
|
726
|
+
typeof decision.approved !== "boolean" ||
|
|
727
|
+
!evidenceExists(decision.evidenceRef))
|
|
728
|
+
throw new Error("审查只绑定职责内未领取的原prepared动作");
|
|
729
|
+
if (!decision.approved)
|
|
730
|
+
action.status = "skipped";
|
|
731
|
+
else
|
|
732
|
+
action.quality = {
|
|
733
|
+
approved: true,
|
|
734
|
+
evidenceRef: decision.evidenceRef,
|
|
735
|
+
...(action.kind === "follow"
|
|
736
|
+
? {
|
|
737
|
+
handle: action.handle,
|
|
738
|
+
blue: decision.blue === true,
|
|
739
|
+
chinese: decision.chinese === true,
|
|
740
|
+
}
|
|
741
|
+
: {
|
|
742
|
+
approvedText: decision.approvedText,
|
|
743
|
+
...(action.kind === "original"
|
|
744
|
+
? { draftFingerprint: decision.draftFingerprint }
|
|
745
|
+
: {}),
|
|
746
|
+
}),
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
next.updatedAt = stamp();
|
|
750
|
+
return next;
|
|
751
|
+
});
|
|
752
|
+
for (const kind of activeKinds) {
|
|
753
|
+
const queue = (snapshot.state.actions ?? [])
|
|
754
|
+
.filter((a) => a.kind === kind && (!selector || selector(a)))
|
|
755
|
+
.sort((a, b) => Number(["intent", "unknown"].includes(b.status)) -
|
|
756
|
+
Number(["intent", "unknown"].includes(a.status)));
|
|
757
|
+
if (snapshot.state.items.find((i) => i.kind === kind).unknown >
|
|
758
|
+
snapshot.state.actions.filter((a) => a.kind === kind && ["intent", "unknown"].includes(a.status)).length) {
|
|
759
|
+
needs.push({
|
|
760
|
+
status: "needsDecision",
|
|
761
|
+
reason: "original-unmapped-unknown-needs-dedup",
|
|
762
|
+
kind,
|
|
763
|
+
});
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
for (const original of queue) {
|
|
767
|
+
if (completed >= maxActions)
|
|
768
|
+
break;
|
|
769
|
+
if (["verified", "preexisting", "skipped"].includes(original.status))
|
|
770
|
+
continue;
|
|
771
|
+
try {
|
|
772
|
+
snapshot = await store.transaction((latest) => {
|
|
773
|
+
const next = structuredClone(latest.state), action = next.actions.find((a) => a.id === original.id);
|
|
774
|
+
if (next.batchId !== taskId)
|
|
775
|
+
throw new Error("original-task-changed");
|
|
776
|
+
if (["verified", "preexisting", "skipped"].includes(action.status))
|
|
777
|
+
return null;
|
|
778
|
+
const sameTarget = (other) => other.kind === action.kind &&
|
|
779
|
+
(kind === "follow"
|
|
780
|
+
? same(other.handle, action.handle)
|
|
781
|
+
: kind === "original"
|
|
782
|
+
? other.draftPath === action.draftPath
|
|
783
|
+
: other.postId === action.postId &&
|
|
784
|
+
other.text === action.text);
|
|
785
|
+
if (next.actions.some((a) => a.id !== action.id &&
|
|
786
|
+
sameTarget(a) &&
|
|
787
|
+
(a.claim ||
|
|
788
|
+
["intent", "unknown", "verified"].includes(a.status))))
|
|
789
|
+
throw new Error("same-target-already-claimed-or-submitted");
|
|
790
|
+
if (action.claim && store.ownerAlive(action.claim.pid))
|
|
791
|
+
throw new Error("same-action-claimed");
|
|
792
|
+
if (Object.values(next.executions).some((e) => e.tabId === executor.tabId &&
|
|
793
|
+
e.actionId &&
|
|
794
|
+
e.runtimeId !== runtimeId &&
|
|
795
|
+
store.ownerAlive(e.pid)))
|
|
796
|
+
throw new Error("same-tab-claimed");
|
|
797
|
+
claim = {
|
|
798
|
+
actionId: action.id,
|
|
799
|
+
token: randomUUID(),
|
|
800
|
+
executorId: executor.id,
|
|
801
|
+
runtimeId,
|
|
802
|
+
pid: store.processId,
|
|
803
|
+
acquiredAt: stamp(),
|
|
804
|
+
};
|
|
805
|
+
action.claim = claim;
|
|
806
|
+
next.executions[executor.id].actionId = action.id;
|
|
807
|
+
next.updatedAt = stamp();
|
|
808
|
+
return next;
|
|
809
|
+
});
|
|
810
|
+
if (!claim)
|
|
811
|
+
continue;
|
|
812
|
+
const outcome = await execute(snapshot, snapshot.state.actions.find((a) => a.id === original.id));
|
|
813
|
+
snapshot = outcome.snapshot;
|
|
814
|
+
completed++;
|
|
815
|
+
if (outcome.need) {
|
|
816
|
+
needs.push(outcome.need);
|
|
817
|
+
break;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
needs.push({
|
|
822
|
+
status: "needsDecision",
|
|
823
|
+
kind,
|
|
824
|
+
actionId: original.id,
|
|
825
|
+
reason: /claimed|same-target/.test(error.message)
|
|
826
|
+
? "action-or-tab-claimed"
|
|
827
|
+
: /budget|capacity/.test(error.message)
|
|
828
|
+
? "shared-reservation-unavailable"
|
|
829
|
+
: "harness-boundary-error",
|
|
830
|
+
error: error.message.slice(0, 240),
|
|
831
|
+
});
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
834
|
+
finally {
|
|
835
|
+
if (claim) {
|
|
836
|
+
const owned = claim;
|
|
837
|
+
claim = null;
|
|
838
|
+
snapshot = await store.transaction((latest) => {
|
|
839
|
+
const next = structuredClone(latest.state), action = next.actions?.find((a) => a.id === owned.actionId);
|
|
840
|
+
if (action?.claim?.token !== owned.token)
|
|
841
|
+
return null;
|
|
842
|
+
delete action.claim;
|
|
843
|
+
if (next.executions[executor.id]?.runtimeId === runtimeId)
|
|
844
|
+
delete next.executions[executor.id].actionId;
|
|
845
|
+
next.updatedAt = stamp();
|
|
846
|
+
return next;
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
snapshot = await store.transaction(async (latest) => {
|
|
853
|
+
const next = structuredClone(latest.state), own = next.executions[executor.id];
|
|
854
|
+
const pending = next.items.filter((i) => activeKinds.includes(i.kind) &&
|
|
855
|
+
(i.verified < i.planned || i.unknown));
|
|
856
|
+
own.status = pending.length ? "RUNNING" : "COMPLETE";
|
|
857
|
+
const scheduled = own.nextScheduledCheckAt ??
|
|
858
|
+
next.execution?.nextScheduledCheckAt ??
|
|
859
|
+
next.stop?.nextScheduledCheckAt;
|
|
860
|
+
const waits = pending.map((i) => needs.find((n) => n.status === "waiting" &&
|
|
861
|
+
next.actions.find((a) => a.id === n.actionId)?.kind ===
|
|
862
|
+
i.kind));
|
|
863
|
+
if (pending.length &&
|
|
864
|
+
pending.every((i) => !i.unknown &&
|
|
865
|
+
next.actions.filter((a) => a.kind === i.kind && a.status === "prepared").length >=
|
|
866
|
+
i.planned - i.verified) &&
|
|
867
|
+
waits.every((w) => w && Date.parse(w.resumeAt) > clock()) &&
|
|
868
|
+
Date.parse(scheduled) > clock()) {
|
|
869
|
+
for (let i = 0; i < pending.length; i++) {
|
|
870
|
+
const proof = await store.recordProof({
|
|
871
|
+
observedAt: stamp(),
|
|
872
|
+
...waits[i],
|
|
873
|
+
attempts: next.attempts,
|
|
874
|
+
});
|
|
875
|
+
Object.assign(pending[i], {
|
|
876
|
+
actionable: false,
|
|
877
|
+
reason: `harness:${waits[i].reason}`,
|
|
878
|
+
resumeAt: waits[i].resumeAt,
|
|
879
|
+
resumeWhen: `不早于${waits[i].resumeAt}重核`,
|
|
880
|
+
blockEvidence: [proof],
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
own.status = "WAITING";
|
|
884
|
+
own.stop = {
|
|
885
|
+
independentActionsHandled: true,
|
|
886
|
+
nextScheduledCheckAt: scheduled,
|
|
887
|
+
nextEligibleCheckAt: waits.map((w) => w.resumeAt).sort()[0],
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
next.status = next.items.every((i) => i.verified === i.planned && !i.unknown)
|
|
891
|
+
? "COMPLETE"
|
|
892
|
+
: "RUNNING";
|
|
893
|
+
own.updatedAt = next.updatedAt = stamp();
|
|
894
|
+
return next;
|
|
895
|
+
});
|
|
896
|
+
const out = result(snapshot, needs);
|
|
897
|
+
if (!out.mayEnd && !needs.length && completed >= maxActions)
|
|
898
|
+
return {
|
|
899
|
+
...out,
|
|
900
|
+
status: "checkpoint",
|
|
901
|
+
checkpoint: {
|
|
902
|
+
taskId,
|
|
903
|
+
executorId: executor.id,
|
|
904
|
+
completed,
|
|
905
|
+
resume: "resumeGrowthRound",
|
|
906
|
+
},
|
|
907
|
+
};
|
|
908
|
+
if (!out.mayEnd && !needs.length)
|
|
909
|
+
out.decision = [
|
|
910
|
+
{ reason: "prepared-queue-does-not-cover-original-targets" },
|
|
911
|
+
];
|
|
912
|
+
return out;
|
|
913
|
+
}
|
|
914
|
+
catch (error) {
|
|
915
|
+
return {
|
|
916
|
+
status: "needsDecision",
|
|
917
|
+
mayEnd: false,
|
|
918
|
+
taskId,
|
|
919
|
+
evidenceRef: store.path,
|
|
920
|
+
decision: [
|
|
921
|
+
{
|
|
922
|
+
reason: "harness-boundary-error",
|
|
923
|
+
error: error.message.slice(0, 240),
|
|
924
|
+
},
|
|
925
|
+
],
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
finally {
|
|
929
|
+
try {
|
|
930
|
+
await releaseSession(taskId);
|
|
931
|
+
}
|
|
932
|
+
finally {
|
|
933
|
+
running = false;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
async function round({ taskId, decision, selector, requestedKind, } = {}) {
|
|
938
|
+
try {
|
|
939
|
+
return await store.withLock(async () => {
|
|
940
|
+
let snapshot = await load(taskId);
|
|
941
|
+
const activeKinds = snapshot.state.scopeKinds ?? kinds;
|
|
942
|
+
if (requestedKind && !activeKinds.includes(requestedKind))
|
|
943
|
+
return {
|
|
944
|
+
...result(snapshot),
|
|
945
|
+
status: "needsDecision",
|
|
946
|
+
mayEnd: false,
|
|
947
|
+
decision: [
|
|
948
|
+
{ kind: requestedKind, reason: "kind-outside-round-scope" },
|
|
949
|
+
],
|
|
950
|
+
};
|
|
951
|
+
if (decision?.type === "prepareActions") {
|
|
952
|
+
if (!Array.isArray(decision.actions) ||
|
|
953
|
+
!/^[A-Za-z0-9_]{1,15}$/.test(decision.accountHandle ?? "") ||
|
|
954
|
+
!evidenceExists(decision.ownershipEvidenceRef) ||
|
|
955
|
+
(snapshot.state.execution?.owner &&
|
|
956
|
+
snapshot.state.execution.owner !== "harness"))
|
|
957
|
+
throw new Error("需原账号执行权核验和完整动作队列;不接管已有执行者");
|
|
958
|
+
if (snapshot.state.accountHandle &&
|
|
959
|
+
!same(snapshot.state.accountHandle, decision.accountHandle))
|
|
960
|
+
throw new Error("不能变更原账号");
|
|
961
|
+
const next = structuredClone(snapshot.state);
|
|
962
|
+
next.actions ??= [];
|
|
963
|
+
for (const action of decision.actions) {
|
|
964
|
+
if (!action.id ||
|
|
965
|
+
!activeKinds.includes(action.kind) ||
|
|
966
|
+
action.status !== "prepared" ||
|
|
967
|
+
next.actions.some((a) => a.id === action.id))
|
|
968
|
+
throw new Error("仅添加职责范围内唯一ID的prepared原动作");
|
|
969
|
+
next.actions.push(structuredClone(action));
|
|
970
|
+
}
|
|
971
|
+
if (next.items.some((item) => next.actions.filter((a) => a.kind === item.kind && a.status === "prepared").length >
|
|
972
|
+
item.planned - item.verified - item.unknown))
|
|
973
|
+
throw new Error("prepared队列不能越过原批次未决目标");
|
|
974
|
+
next.accountHandle = decision.accountHandle;
|
|
975
|
+
next.execution = {
|
|
976
|
+
...next.execution,
|
|
977
|
+
owner: "harness",
|
|
978
|
+
exclusive: true,
|
|
979
|
+
ownershipEvidenceRef: decision.ownershipEvidenceRef,
|
|
980
|
+
...(decision.nextScheduledCheckAt
|
|
981
|
+
? { nextScheduledCheckAt: decision.nextScheduledCheckAt }
|
|
982
|
+
: {}),
|
|
983
|
+
};
|
|
984
|
+
if (decision.attemptsCoverage) {
|
|
985
|
+
if (!decision.attemptsCoverage.complete ||
|
|
986
|
+
!evidenceExists(decision.attemptsCoverage.evidenceRef))
|
|
987
|
+
throw new Error("共享历史须完整取证;不能默认为零");
|
|
988
|
+
next.attemptsCoverage = structuredClone(decision.attemptsCoverage);
|
|
989
|
+
}
|
|
990
|
+
if (decision.sharedAttempts) {
|
|
991
|
+
if (!Array.isArray(decision.sharedAttempts))
|
|
992
|
+
throw new Error("共享尝试记录必须为数组");
|
|
993
|
+
next.attempts = [
|
|
994
|
+
...(next.attempts ?? []),
|
|
995
|
+
...structuredClone(decision.sharedAttempts),
|
|
996
|
+
];
|
|
997
|
+
}
|
|
998
|
+
snapshot = await save(snapshot, next);
|
|
999
|
+
}
|
|
1000
|
+
else if (decision) {
|
|
1001
|
+
const action = snapshot.state.actions?.find((a) => a.id === decision.actionId);
|
|
1002
|
+
if (action && !activeKinds.includes(action.kind))
|
|
1003
|
+
throw new Error("kind-outside-round-scope");
|
|
1004
|
+
if (!action ||
|
|
1005
|
+
action.status !== "prepared" ||
|
|
1006
|
+
typeof decision.approved !== "boolean" ||
|
|
1007
|
+
!evidenceExists(decision.evidenceRef))
|
|
1008
|
+
throw new Error("决策仅能绑定原prepared动作及真实审查证据");
|
|
1009
|
+
const next = structuredClone(snapshot.state), target = next.actions.find((a) => a.id === action.id);
|
|
1010
|
+
if (!decision.approved)
|
|
1011
|
+
target.status = "skipped";
|
|
1012
|
+
else
|
|
1013
|
+
target.quality = {
|
|
1014
|
+
approved: true,
|
|
1015
|
+
evidenceRef: decision.evidenceRef,
|
|
1016
|
+
...(action.kind === "follow"
|
|
1017
|
+
? {
|
|
1018
|
+
handle: action.handle,
|
|
1019
|
+
blue: decision.blue === true,
|
|
1020
|
+
chinese: decision.chinese === true,
|
|
1021
|
+
}
|
|
1022
|
+
: {
|
|
1023
|
+
approvedText: decision.approvedText,
|
|
1024
|
+
...(action.kind === "original"
|
|
1025
|
+
? { draftFingerprint: decision.draftFingerprint }
|
|
1026
|
+
: {}),
|
|
1027
|
+
}),
|
|
1028
|
+
};
|
|
1029
|
+
snapshot = await save(snapshot, next);
|
|
1030
|
+
}
|
|
1031
|
+
const needs = [];
|
|
1032
|
+
for (const kind of activeKinds) {
|
|
1033
|
+
const queue = (snapshot.state.actions ?? [])
|
|
1034
|
+
.filter((a) => a.kind === kind && (!selector || selector(a)))
|
|
1035
|
+
.sort((a, b) => Number(["intent", "unknown"].includes(b.status)) -
|
|
1036
|
+
Number(["intent", "unknown"].includes(a.status)));
|
|
1037
|
+
// A selector narrows this invocation, not the original unresolved ledger.
|
|
1038
|
+
const unresolved = (snapshot.state.actions ?? []).filter((a) => a.kind === kind && ["intent", "unknown"].includes(a.status)).length;
|
|
1039
|
+
if (snapshot.state.items.find((i) => i.kind === kind).unknown >
|
|
1040
|
+
unresolved) {
|
|
1041
|
+
needs.push({
|
|
1042
|
+
status: "needsDecision",
|
|
1043
|
+
reason: "original-unmapped-unknown-needs-dedup",
|
|
1044
|
+
kind,
|
|
1045
|
+
});
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
for (const original of queue) {
|
|
1049
|
+
const item = snapshot.state.items.find((i) => i.kind === kind);
|
|
1050
|
+
const action = snapshot.state.actions.find((a) => a.id === original.id);
|
|
1051
|
+
if (["verified", "preexisting", "skipped"].includes(action.status))
|
|
1052
|
+
continue;
|
|
1053
|
+
if (item.verified >= item.planned && action.status === "prepared")
|
|
1054
|
+
continue;
|
|
1055
|
+
const outcome = await execute(snapshot, action);
|
|
1056
|
+
snapshot = outcome.snapshot;
|
|
1057
|
+
if (outcome.need) {
|
|
1058
|
+
needs.push(outcome.need);
|
|
1059
|
+
break;
|
|
1060
|
+
} // Stop only this kind; continue independent kinds.
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
if (snapshot.state.items
|
|
1064
|
+
.filter((i) => activeKinds.includes(i.kind))
|
|
1065
|
+
.every((i) => i.verified === i.planned && i.unknown === 0)) {
|
|
1066
|
+
const next = structuredClone(snapshot.state);
|
|
1067
|
+
next.status = "COMPLETE";
|
|
1068
|
+
snapshot = await save(snapshot, next);
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
const pending = snapshot.state.items.filter((i) => activeKinds.includes(i.kind) &&
|
|
1072
|
+
(i.verified < i.planned || i.unknown > 0));
|
|
1073
|
+
const waits = pending.map((item) => needs.find((n) => n.status === "waiting" &&
|
|
1074
|
+
snapshot.state.actions.find((a) => a.id === n.actionId)
|
|
1075
|
+
?.kind === item.kind));
|
|
1076
|
+
const scheduled = snapshot.state.execution?.nextScheduledCheckAt ??
|
|
1077
|
+
snapshot.state.stop?.nextScheduledCheckAt;
|
|
1078
|
+
const covered = pending.every((item) => item.unknown === 0 &&
|
|
1079
|
+
(snapshot.state.actions ?? []).filter((a) => a.kind === item.kind && a.status === "prepared").length >=
|
|
1080
|
+
item.planned - item.verified);
|
|
1081
|
+
if (covered &&
|
|
1082
|
+
waits.every((w) => w && Date.parse(w.resumeAt) > clock()) &&
|
|
1083
|
+
Date.parse(scheduled) > clock()) {
|
|
1084
|
+
const next = structuredClone(snapshot.state);
|
|
1085
|
+
for (let i = 0; i < pending.length; i++) {
|
|
1086
|
+
const item = next.items.find((a) => a.kind === pending[i].kind), wait = waits[i];
|
|
1087
|
+
const proof = await store.recordProof({
|
|
1088
|
+
observedAt: stamp(),
|
|
1089
|
+
reason: wait.reason,
|
|
1090
|
+
resumeAt: wait.resumeAt,
|
|
1091
|
+
attempts: next.attempts,
|
|
1092
|
+
});
|
|
1093
|
+
Object.assign(item, {
|
|
1094
|
+
actionable: false,
|
|
1095
|
+
reason: `harness:${wait.reason}`,
|
|
1096
|
+
resumeAt: wait.resumeAt,
|
|
1097
|
+
resumeWhen: `不早于${wait.resumeAt}重核预算及fresh状态`,
|
|
1098
|
+
blockEvidence: [proof],
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
next.status = "WAITING";
|
|
1102
|
+
next.stop = {
|
|
1103
|
+
independentActionsHandled: true,
|
|
1104
|
+
nextScheduledCheckAt: scheduled,
|
|
1105
|
+
nextEligibleCheckAt: waits.map((w) => w.resumeAt).sort()[0],
|
|
1106
|
+
};
|
|
1107
|
+
snapshot = await save(snapshot, next);
|
|
1108
|
+
}
|
|
1109
|
+
else if (!needs.length)
|
|
1110
|
+
needs.push({
|
|
1111
|
+
status: "needsDecision",
|
|
1112
|
+
reason: "prepared-queue-does-not-cover-original-targets",
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
return result(snapshot, needs);
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
catch (error) {
|
|
1119
|
+
return {
|
|
1120
|
+
status: "needsDecision",
|
|
1121
|
+
mayEnd: false,
|
|
1122
|
+
taskId,
|
|
1123
|
+
evidenceRef: store.path,
|
|
1124
|
+
decision: [
|
|
1125
|
+
{
|
|
1126
|
+
reason: error.code === "CONFLICT"
|
|
1127
|
+
? "original-ledger-conflict"
|
|
1128
|
+
: "harness-boundary-error",
|
|
1129
|
+
error: error.message.slice(0, 240),
|
|
1130
|
+
},
|
|
1131
|
+
],
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
// Independent tabs share only short ledger transactions, never a browser-operation lock.
|
|
1136
|
+
async function publishIndependent({ taskId, draftPath, preparedAction, independentExecution, }) {
|
|
1137
|
+
let action, token, submissionStarted = false;
|
|
1138
|
+
const inWindow = () => {
|
|
1139
|
+
const hour = Number(new Intl.DateTimeFormat("en-GB", {
|
|
1140
|
+
timeZone: "Asia/Shanghai",
|
|
1141
|
+
hour: "2-digit",
|
|
1142
|
+
hourCycle: "h23",
|
|
1143
|
+
}).format(clock()));
|
|
1144
|
+
return hour >= 7 && hour < 22;
|
|
1145
|
+
};
|
|
1146
|
+
try {
|
|
1147
|
+
const caps = capability("submitPost", preparedAction ?? {});
|
|
1148
|
+
if (!caps.covered)
|
|
1149
|
+
return { status: "needsCapability", ...caps };
|
|
1150
|
+
if (!inWindow())
|
|
1151
|
+
throw new Error("outside-contract-write-window");
|
|
1152
|
+
await store.withLock(async () => {
|
|
1153
|
+
const snapshot = await load(taskId), state = structuredClone(snapshot.state);
|
|
1154
|
+
state.actions ??= [];
|
|
1155
|
+
action = state.actions.find((a) => a.kind === "original" && a.draftPath === draftPath);
|
|
1156
|
+
if (!action) {
|
|
1157
|
+
if (!preparedAction?.id ||
|
|
1158
|
+
preparedAction.kind !== "original" ||
|
|
1159
|
+
preparedAction.status !== "prepared" ||
|
|
1160
|
+
preparedAction.draftPath !== draftPath ||
|
|
1161
|
+
state.actions.some((a) => a.id === preparedAction.id))
|
|
1162
|
+
throw new Error("unique-prepared-original-required");
|
|
1163
|
+
const error = guardInput(state, preparedAction, independentExecution);
|
|
1164
|
+
if (error)
|
|
1165
|
+
throw new Error(error);
|
|
1166
|
+
action = structuredClone(preparedAction);
|
|
1167
|
+
state.actions.push(action);
|
|
1168
|
+
await save(snapshot, state);
|
|
1169
|
+
}
|
|
1170
|
+
if (action.status !== "prepared")
|
|
1171
|
+
throw new Error("existing-submission-do-not-resubmit");
|
|
1172
|
+
if (action.claim && store.ownerAlive(action.claim.pid))
|
|
1173
|
+
throw new Error("same-action-claimed");
|
|
1174
|
+
const error = guardInput(state, action, independentExecution);
|
|
1175
|
+
if (error)
|
|
1176
|
+
throw new Error(error);
|
|
1177
|
+
});
|
|
1178
|
+
const own = await platform.readAccount();
|
|
1179
|
+
if (own.needsStateCheck ||
|
|
1180
|
+
own.alerts?.length ||
|
|
1181
|
+
!same(own.accountHandle, action.accountHandle))
|
|
1182
|
+
throw new Error("fresh-account-unconfirmed");
|
|
1183
|
+
const baseline = await platform.findSubmission({
|
|
1184
|
+
...action,
|
|
1185
|
+
intentAt: stamp(),
|
|
1186
|
+
});
|
|
1187
|
+
if (baseline.needsStateCheck || baseline.alerts?.length)
|
|
1188
|
+
throw new Error("publication-baseline-unconfirmed");
|
|
1189
|
+
token = `${action.id}:${stamp()}:${Math.random()}`;
|
|
1190
|
+
await store.withLock(async () => {
|
|
1191
|
+
const snapshot = await load(taskId), state = structuredClone(snapshot.state);
|
|
1192
|
+
const target = state.actions.find((a) => a.id === action.id), item = state.items.find((i) => i.kind === "original");
|
|
1193
|
+
if (target?.status !== "prepared" ||
|
|
1194
|
+
(target.claim && store.ownerAlive(target.claim.pid)))
|
|
1195
|
+
throw new Error("action-already-claimed-do-not-resubmit");
|
|
1196
|
+
const error = guardInput(state, target, independentExecution);
|
|
1197
|
+
if (error)
|
|
1198
|
+
throw new Error(error);
|
|
1199
|
+
if (!inWindow() || item.verified + item.unknown >= item.planned)
|
|
1200
|
+
throw new Error("window-or-original-capacity-unavailable");
|
|
1201
|
+
Object.assign(target, {
|
|
1202
|
+
status: "intent",
|
|
1203
|
+
intentAt: stamp(),
|
|
1204
|
+
beforeIds: (baseline.posts ?? [])
|
|
1205
|
+
.map((p) => p.id)
|
|
1206
|
+
.filter(Boolean),
|
|
1207
|
+
claimToken: token,
|
|
1208
|
+
independentExecution,
|
|
1209
|
+
claim: {
|
|
1210
|
+
actionId: target.id,
|
|
1211
|
+
token,
|
|
1212
|
+
pid: store.processId,
|
|
1213
|
+
tabId: independentExecution.tabId,
|
|
1214
|
+
acquiredAt: stamp(),
|
|
1215
|
+
},
|
|
1216
|
+
});
|
|
1217
|
+
item.unknown += 1;
|
|
1218
|
+
state.attempts ??= [];
|
|
1219
|
+
state.attempts.push({
|
|
1220
|
+
actionId: target.id,
|
|
1221
|
+
kind: "original",
|
|
1222
|
+
at: target.intentAt,
|
|
1223
|
+
});
|
|
1224
|
+
await save(snapshot, state);
|
|
1225
|
+
action = structuredClone(target);
|
|
1226
|
+
});
|
|
1227
|
+
let observation;
|
|
1228
|
+
try {
|
|
1229
|
+
await platform.submitPost({
|
|
1230
|
+
...action,
|
|
1231
|
+
beforeSubmit: async () => {
|
|
1232
|
+
const latest = await load(taskId), target = latest.state.actions.find((a) => a.id === action.id);
|
|
1233
|
+
if (target?.status !== "intent" ||
|
|
1234
|
+
target.claimToken !== token ||
|
|
1235
|
+
!inWindow())
|
|
1236
|
+
throw new Error("action-claim-or-window-changed");
|
|
1237
|
+
const error = guardInput(latest.state, target, independentExecution);
|
|
1238
|
+
if (error)
|
|
1239
|
+
throw new Error(error);
|
|
1240
|
+
submissionStarted = true;
|
|
1241
|
+
},
|
|
1242
|
+
});
|
|
1243
|
+
observation = await platform.findSubmission(action);
|
|
1244
|
+
}
|
|
1245
|
+
catch (error) {
|
|
1246
|
+
if (!submissionStarted)
|
|
1247
|
+
throw error;
|
|
1248
|
+
observation = {
|
|
1249
|
+
verified: false,
|
|
1250
|
+
missing: ["submission-or-verification-failed"],
|
|
1251
|
+
error: error.message.slice(0, 240),
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
return await store.withLock(async () => {
|
|
1255
|
+
const snapshot = await load(taskId), target = snapshot.state.actions.find((a) => a.id === action.id);
|
|
1256
|
+
if (target?.claimToken !== token || target.status !== "intent")
|
|
1257
|
+
throw new Error("action-settlement-conflict");
|
|
1258
|
+
await settleProof(snapshot, target, observation, {
|
|
1259
|
+
preserveStatus: true,
|
|
1260
|
+
});
|
|
1261
|
+
return {
|
|
1262
|
+
status: observation.verified ? "verified" : "unknown",
|
|
1263
|
+
taskId,
|
|
1264
|
+
actionId: action.id,
|
|
1265
|
+
publicId: observation.publicId,
|
|
1266
|
+
evidenceRef: store.path,
|
|
1267
|
+
mayEnd: false,
|
|
1268
|
+
};
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
catch (error) {
|
|
1272
|
+
if (token && !submissionStarted)
|
|
1273
|
+
await store.withLock(async () => {
|
|
1274
|
+
const snapshot = await load(taskId), state = structuredClone(snapshot.state), target = state.actions.find((a) => a.id === action.id);
|
|
1275
|
+
if (target?.claimToken !== token || target.status !== "intent")
|
|
1276
|
+
return;
|
|
1277
|
+
target.status = "prepared";
|
|
1278
|
+
target.preparationEvidence = await store.recordProof({
|
|
1279
|
+
observedAt: stamp(),
|
|
1280
|
+
submissionStarted: false,
|
|
1281
|
+
error: error.message.slice(0, 240),
|
|
1282
|
+
});
|
|
1283
|
+
state.items.find((i) => i.kind === "original").unknown -= 1;
|
|
1284
|
+
await save(snapshot, state);
|
|
1285
|
+
});
|
|
1286
|
+
return {
|
|
1287
|
+
status: "needsDecision",
|
|
1288
|
+
taskId,
|
|
1289
|
+
mayEnd: false,
|
|
1290
|
+
decision: [
|
|
1291
|
+
{
|
|
1292
|
+
reason: error.code === "CONFLICT"
|
|
1293
|
+
? "original-ledger-conflict"
|
|
1294
|
+
: error.message.slice(0, 240),
|
|
1295
|
+
},
|
|
1296
|
+
],
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
finally {
|
|
1300
|
+
if (token)
|
|
1301
|
+
await store.transaction((snapshot) => {
|
|
1302
|
+
if (snapshot.state.batchId !== taskId)
|
|
1303
|
+
return null;
|
|
1304
|
+
const next = structuredClone(snapshot.state), target = next.actions.find((a) => a.id === action.id);
|
|
1305
|
+
if (target?.claim?.token !== token)
|
|
1306
|
+
return null;
|
|
1307
|
+
delete target.claim;
|
|
1308
|
+
return next;
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
async function releaseSession(taskId = undefined) {
|
|
1313
|
+
if (!executor)
|
|
1314
|
+
return;
|
|
1315
|
+
await store.transaction((latest) => {
|
|
1316
|
+
const own = latest.state.executions?.[executor.id];
|
|
1317
|
+
if ((taskId && latest.state.batchId !== taskId) ||
|
|
1318
|
+
own?.runtimeId !== runtimeId ||
|
|
1319
|
+
!own.activity)
|
|
1320
|
+
return null;
|
|
1321
|
+
const next = structuredClone(latest.state);
|
|
1322
|
+
delete next.executions[executor.id].activity;
|
|
1323
|
+
next.updatedAt = stamp();
|
|
1324
|
+
return next;
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
async function readTask(operation, { mode = "receipt", ...input } = {}) {
|
|
1328
|
+
if (!["decision", "receipt", "delta"].includes(mode))
|
|
1329
|
+
throw new Error("读取模式须为decision/receipt/delta");
|
|
1330
|
+
if (running)
|
|
1331
|
+
return {
|
|
1332
|
+
status: "needsDecision",
|
|
1333
|
+
complete: false,
|
|
1334
|
+
reason: "same-tab-operation-running",
|
|
1335
|
+
};
|
|
1336
|
+
const caps = platform.preflight({
|
|
1337
|
+
operation: ["readAccount", "readPerson", "readPost"].includes(operation)
|
|
1338
|
+
? "read"
|
|
1339
|
+
: operation,
|
|
1340
|
+
input,
|
|
1341
|
+
});
|
|
1342
|
+
if (!caps.covered || typeof platform[operation] !== "function")
|
|
1343
|
+
return {
|
|
1344
|
+
status: "needsCapability",
|
|
1345
|
+
...caps,
|
|
1346
|
+
missing: [
|
|
1347
|
+
...(caps.missing ?? []),
|
|
1348
|
+
...(typeof platform[operation] === "function"
|
|
1349
|
+
? []
|
|
1350
|
+
: [`platform.${operation}`]),
|
|
1351
|
+
],
|
|
1352
|
+
};
|
|
1353
|
+
running = true;
|
|
1354
|
+
try {
|
|
1355
|
+
if (executor)
|
|
1356
|
+
await store.transaction((latest) => {
|
|
1357
|
+
const next = structuredClone(latest.state);
|
|
1358
|
+
next.executions ??= {};
|
|
1359
|
+
if (Object.values(next.executions).some((e) => (e.tabId === executor.tabId || e.id === executor.id) &&
|
|
1360
|
+
e.runtimeId !== runtimeId &&
|
|
1361
|
+
(e.activity || e.actionId) &&
|
|
1362
|
+
store.ownerAlive(e.pid)))
|
|
1363
|
+
throw new Error("same-tab-or-executor-claimed");
|
|
1364
|
+
next.executions[executor.id] = {
|
|
1365
|
+
...next.executions[executor.id],
|
|
1366
|
+
...executor,
|
|
1367
|
+
runtimeId,
|
|
1368
|
+
pid: store.processId,
|
|
1369
|
+
activity: operation,
|
|
1370
|
+
status: next.executions[executor.id]?.status ?? "RUNNING",
|
|
1371
|
+
updatedAt: stamp(),
|
|
1372
|
+
};
|
|
1373
|
+
next.updatedAt = stamp();
|
|
1374
|
+
return next;
|
|
1375
|
+
});
|
|
1376
|
+
const value = await platform[operation](input);
|
|
1377
|
+
const evidenceRef = await store.recordProof(await platform.captureProof({ observation: value }));
|
|
1378
|
+
const status = value.status ??
|
|
1379
|
+
(value.complete === false ||
|
|
1380
|
+
value.needsStateCheck ||
|
|
1381
|
+
value.alerts?.length
|
|
1382
|
+
? "needsDecision"
|
|
1383
|
+
: "observed");
|
|
1384
|
+
const receipt = {
|
|
1385
|
+
status,
|
|
1386
|
+
fresh: true,
|
|
1387
|
+
evidenceRef,
|
|
1388
|
+
complete: status === "observed" && (value.complete ?? !value.needsStateCheck),
|
|
1389
|
+
...((value.sources ?? value.feedback)
|
|
1390
|
+
? { ids: (value.sources ?? value.feedback).map((p) => p.id) }
|
|
1391
|
+
: {}),
|
|
1392
|
+
...(operation === "readAccount"
|
|
1393
|
+
? {
|
|
1394
|
+
accountHandle: value.accountHandle ?? null,
|
|
1395
|
+
accountURL: value.accountURL ?? null,
|
|
1396
|
+
}
|
|
1397
|
+
: {}),
|
|
1398
|
+
...(value.missing?.length ? { missing: value.missing } : {}),
|
|
1399
|
+
};
|
|
1400
|
+
if (mode === "receipt")
|
|
1401
|
+
return receipt;
|
|
1402
|
+
const collection = value.sources
|
|
1403
|
+
? "sources"
|
|
1404
|
+
: value.feedback
|
|
1405
|
+
? "feedback"
|
|
1406
|
+
: null;
|
|
1407
|
+
if (mode === "delta" && collection) {
|
|
1408
|
+
const unchangedIds = [], changed = [];
|
|
1409
|
+
for (const entry of value[collection]) {
|
|
1410
|
+
const key = JSON.stringify([operation, input, entry.id]);
|
|
1411
|
+
const normalized = JSON.stringify(entry, (name, val) => name === "observedAt" ? undefined : val);
|
|
1412
|
+
if (readCache.get(key) === normalized)
|
|
1413
|
+
unchangedIds.push(entry.id);
|
|
1414
|
+
else
|
|
1415
|
+
changed.push(entry);
|
|
1416
|
+
readCache.set(key, normalized);
|
|
1417
|
+
}
|
|
1418
|
+
return { ...receipt, [collection]: changed, unchangedIds };
|
|
1419
|
+
}
|
|
1420
|
+
return mode === "delta" ? receipt : { ...value, ...receipt };
|
|
1421
|
+
}
|
|
1422
|
+
catch (error) {
|
|
1423
|
+
const category = /connection|transport|interrupted|extension/i.test(error.message)
|
|
1424
|
+
? "browser-connection"
|
|
1425
|
+
: "ui-not-confirmed";
|
|
1426
|
+
const code = error.code === "CONFLICT"
|
|
1427
|
+
? "ledger-conflict"
|
|
1428
|
+
: /same-tab-or-executor-claimed/.test(error.message)
|
|
1429
|
+
? "tab-busy"
|
|
1430
|
+
: category === "browser-connection"
|
|
1431
|
+
? "connection-failed"
|
|
1432
|
+
: "observation-unconfirmed";
|
|
1433
|
+
const diagnostic = { operation, category, code };
|
|
1434
|
+
const evidenceRef = await store.recordProof({
|
|
1435
|
+
capturedAt: stamp(),
|
|
1436
|
+
diagnostic: { ...diagnostic, error: error.message },
|
|
1437
|
+
});
|
|
1438
|
+
return {
|
|
1439
|
+
status: "needsDecision",
|
|
1440
|
+
fresh: false,
|
|
1441
|
+
complete: false,
|
|
1442
|
+
evidenceRef,
|
|
1443
|
+
diagnostic: mode === "decision"
|
|
1444
|
+
? { ...diagnostic, error: error.message.slice(0, 240) }
|
|
1445
|
+
: diagnostic,
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
finally {
|
|
1449
|
+
try {
|
|
1450
|
+
await releaseSession();
|
|
1451
|
+
}
|
|
1452
|
+
finally {
|
|
1453
|
+
running = false;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
// Stable prepared actions are adopted in the original ledger, never in a second queue.
|
|
1458
|
+
async function prepareActionsForCall(taskId, actions = []) {
|
|
1459
|
+
if (!actions.length)
|
|
1460
|
+
return;
|
|
1461
|
+
await store.transaction(async (snapshot) => {
|
|
1462
|
+
if (snapshot.state.batchId !== taskId)
|
|
1463
|
+
throw new Error("original-task-id-required");
|
|
1464
|
+
const next = structuredClone(snapshot.state);
|
|
1465
|
+
next.actions ??= [];
|
|
1466
|
+
const ids = new Set();
|
|
1467
|
+
for (const action of actions) {
|
|
1468
|
+
if (!action?.id ||
|
|
1469
|
+
ids.has(action.id) ||
|
|
1470
|
+
action.status !== "prepared" ||
|
|
1471
|
+
!kinds.includes(action.kind))
|
|
1472
|
+
throw new Error("unique-prepared-action-required");
|
|
1473
|
+
ids.add(action.id);
|
|
1474
|
+
const inputError = guardInput(next, action);
|
|
1475
|
+
if (inputError)
|
|
1476
|
+
throw new Error(inputError);
|
|
1477
|
+
const existing = next.actions.find((a) => a.id === action.id);
|
|
1478
|
+
if (existing) {
|
|
1479
|
+
const fields = [
|
|
1480
|
+
"kind",
|
|
1481
|
+
"accountHandle",
|
|
1482
|
+
"handle",
|
|
1483
|
+
"postId",
|
|
1484
|
+
"text",
|
|
1485
|
+
"draftPath",
|
|
1486
|
+
];
|
|
1487
|
+
if (fields.some((field) => existing[field] !== action[field]))
|
|
1488
|
+
throw new Error("original-action-input-changed");
|
|
1489
|
+
continue; // intent/unknown/verified stay untouched and follow their original recovery.
|
|
1490
|
+
}
|
|
1491
|
+
const duplicate = next.actions.some((a) => a.kind === action.kind &&
|
|
1492
|
+
(action.kind === "follow"
|
|
1493
|
+
? same(a.handle, action.handle)
|
|
1494
|
+
: action.kind === "original"
|
|
1495
|
+
? a.draftPath === action.draftPath
|
|
1496
|
+
: a.postId === action.postId && a.text === action.text));
|
|
1497
|
+
if (duplicate)
|
|
1498
|
+
throw new Error("business-object-already-prepared");
|
|
1499
|
+
const item = next.items.find((i) => i.kind === action.kind);
|
|
1500
|
+
const queued = next.actions.filter((a) => a.kind === action.kind &&
|
|
1501
|
+
!["skipped", "preexisting"].includes(a.status)).length;
|
|
1502
|
+
if (!item || queued >= item.planned)
|
|
1503
|
+
throw new Error("prepared-actions-exceed-original-target");
|
|
1504
|
+
next.actions.push(structuredClone(action));
|
|
1505
|
+
}
|
|
1506
|
+
next.updatedAt = stamp();
|
|
1507
|
+
return next;
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
return {
|
|
1511
|
+
async verifyPublishedPost({ taskId, draftPath, publicURL, evidenceRef, sourceLink, }) {
|
|
1512
|
+
// Read-only recovery of a single submitted original; never opens a composer.
|
|
1513
|
+
const caps = capability("read");
|
|
1514
|
+
if (!caps.covered || typeof platform.readPost !== "function")
|
|
1515
|
+
return {
|
|
1516
|
+
status: "needsCapability",
|
|
1517
|
+
missing: ["platform.readPost", ...caps.missing],
|
|
1518
|
+
};
|
|
1519
|
+
const initial = await load(taskId);
|
|
1520
|
+
const action = initial.state.actions.find((a) => a.kind === "original" && a.draftPath === draftPath);
|
|
1521
|
+
if (!action ||
|
|
1522
|
+
!["intent", "unknown"].includes(action.status) ||
|
|
1523
|
+
!evidenceExists(evidenceRef))
|
|
1524
|
+
throw new Error("submitted-original-and-public-proof-required");
|
|
1525
|
+
const url = xURL(publicURL), postId = url.pathname.match(/\/status\/(\d+)$/)?.[1];
|
|
1526
|
+
if (!postId ||
|
|
1527
|
+
url.pathname.toLowerCase() !==
|
|
1528
|
+
`/${action.accountHandle}/status/${postId}`.toLowerCase())
|
|
1529
|
+
throw new Error("public-account-mismatch");
|
|
1530
|
+
const observation = await platform.readPost({
|
|
1531
|
+
postId,
|
|
1532
|
+
url: publicURL,
|
|
1533
|
+
evidenceRef,
|
|
1534
|
+
});
|
|
1535
|
+
const trailing = action.text.match(/\n\n(https:\/\/\S+)$/);
|
|
1536
|
+
const matches = (observation.posts ?? []).filter((p) => {
|
|
1537
|
+
const exact = p.body === action.text;
|
|
1538
|
+
const card = trailing &&
|
|
1539
|
+
sourceLink &&
|
|
1540
|
+
evidenceExists(sourceLink.evidenceRef) &&
|
|
1541
|
+
sourceLink.resolvedURL === trailing[1] &&
|
|
1542
|
+
p.links?.includes(sourceLink.href) &&
|
|
1543
|
+
p.body === action.text.slice(0, -trailing[0].length);
|
|
1544
|
+
let renderedSource = false;
|
|
1545
|
+
if (trailing &&
|
|
1546
|
+
sourceLink?.renderedText &&
|
|
1547
|
+
evidenceExists(sourceLink.evidenceRef) &&
|
|
1548
|
+
sourceLink.resolvedURL === trailing[1] &&
|
|
1549
|
+
p.links?.includes(sourceLink.href)) {
|
|
1550
|
+
try {
|
|
1551
|
+
renderedSource =
|
|
1552
|
+
new URL(sourceLink.renderedText.replace(/\s/g, "")).href ===
|
|
1553
|
+
sourceLink.resolvedURL &&
|
|
1554
|
+
p.body?.endsWith(sourceLink.renderedText) &&
|
|
1555
|
+
p.body.slice(0, -sourceLink.renderedText.length).trimEnd() ===
|
|
1556
|
+
action.text.slice(0, -trailing[0].length);
|
|
1557
|
+
}
|
|
1558
|
+
catch {
|
|
1559
|
+
/* Invalid rendered URL cannot verify publication. */
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
return (p.id === postId &&
|
|
1563
|
+
same(p.author, action.accountHandle) &&
|
|
1564
|
+
(exact || card || renderedSource) &&
|
|
1565
|
+
!p.needsExpansion &&
|
|
1566
|
+
!(action.beforeIds ?? []).includes(p.id) &&
|
|
1567
|
+
Date.parse(p.datetime) >= Date.parse(action.intentAt) &&
|
|
1568
|
+
Date.parse(p.datetime) <= clock() + 5000 &&
|
|
1569
|
+
(!action.assets?.length ||
|
|
1570
|
+
action.assets.every((a) => p.images?.some((i) => i.alt === a.alt))));
|
|
1571
|
+
});
|
|
1572
|
+
Object.assign(observation, {
|
|
1573
|
+
verified: !observation.needsStateCheck &&
|
|
1574
|
+
!observation.alerts?.length &&
|
|
1575
|
+
matches.length === 1,
|
|
1576
|
+
publicId: matches.length === 1 ? postId : null,
|
|
1577
|
+
sourceLink,
|
|
1578
|
+
});
|
|
1579
|
+
return store.withLock(async () => {
|
|
1580
|
+
const latest = await load(taskId), target = latest.state.actions.find((a) => a.id === action.id);
|
|
1581
|
+
if (!["intent", "unknown"].includes(target?.status) ||
|
|
1582
|
+
target.intentAt !== action.intentAt ||
|
|
1583
|
+
target.text !== action.text)
|
|
1584
|
+
throw new Error("recovery-action-changed");
|
|
1585
|
+
await settleProof(latest, target, observation, {
|
|
1586
|
+
preserveStatus: true,
|
|
1587
|
+
});
|
|
1588
|
+
return {
|
|
1589
|
+
status: observation.verified ? "verified" : "unknown",
|
|
1590
|
+
publicId: observation.publicId,
|
|
1591
|
+
taskId,
|
|
1592
|
+
actionId: action.id,
|
|
1593
|
+
mayEnd: false,
|
|
1594
|
+
};
|
|
1595
|
+
});
|
|
1596
|
+
},
|
|
1597
|
+
preflight: ({ operation, input = {} }) => {
|
|
1598
|
+
if (["readBookmarks", "searchSources", "readFeedback"].includes(operation))
|
|
1599
|
+
return platform.preflight({ operation, input });
|
|
1600
|
+
if (["inspectRound", "verifySubmission"].includes(operation))
|
|
1601
|
+
return capability("read", input);
|
|
1602
|
+
if (["inspectAccount", "verifyFollow"].includes(operation)) {
|
|
1603
|
+
const name = operation === "verifyFollow" ? "readPerson" : "readAccount";
|
|
1604
|
+
if (typeof platform?.preflight !== "function" ||
|
|
1605
|
+
typeof platform?.[name] !== "function")
|
|
1606
|
+
return {
|
|
1607
|
+
covered: false,
|
|
1608
|
+
missing: [name],
|
|
1609
|
+
owner: "scripts/x-harness-platform.mjs",
|
|
1610
|
+
};
|
|
1611
|
+
return platform.preflight({ operation: "read", input });
|
|
1612
|
+
}
|
|
1613
|
+
if (["runGrowthRound", "resumeGrowthRound"].includes(operation)) {
|
|
1614
|
+
const scoped = executor?.scopeKinds ?? input.scopeKinds ?? kinds;
|
|
1615
|
+
if (!Array.isArray(scoped) ||
|
|
1616
|
+
!scoped.length ||
|
|
1617
|
+
scoped.some((k) => !kinds.includes(k)))
|
|
1618
|
+
return {
|
|
1619
|
+
covered: false,
|
|
1620
|
+
missing: ["scopeKinds"],
|
|
1621
|
+
owner: "scripts/x-growth-harness.mjs",
|
|
1622
|
+
};
|
|
1623
|
+
const all = scoped.map((kind) => capability(operations[kind], { ...input, kind }));
|
|
1624
|
+
return {
|
|
1625
|
+
covered: all.every((c) => c.covered),
|
|
1626
|
+
missing: [...new Set(all.flatMap((c) => c.missing))],
|
|
1627
|
+
owners: [
|
|
1628
|
+
...new Set(all.filter((c) => !c.covered).map((c) => c.owner)),
|
|
1629
|
+
],
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
if (![
|
|
1633
|
+
"read",
|
|
1634
|
+
"followOne",
|
|
1635
|
+
"followBatch",
|
|
1636
|
+
"publishPost",
|
|
1637
|
+
"replyToPost",
|
|
1638
|
+
...Object.values(operations),
|
|
1639
|
+
].includes(operation))
|
|
1640
|
+
return {
|
|
1641
|
+
covered: false,
|
|
1642
|
+
missing: [`operation.${operation}`],
|
|
1643
|
+
owner: "scripts/x-growth-harness.mjs",
|
|
1644
|
+
};
|
|
1645
|
+
return capability(operation, input);
|
|
1646
|
+
},
|
|
1647
|
+
readCreator: (args) => readTask("readCreator", args),
|
|
1648
|
+
inspectAccount: (args) => readTask("readAccount", args),
|
|
1649
|
+
readPost: (args) => readTask("readPost", { ...args, expand: true }),
|
|
1650
|
+
readPerson: (args) => readTask("readPerson", args),
|
|
1651
|
+
readBookmarks: (args) => readTask("readBookmarks", args),
|
|
1652
|
+
searchSources: (args) => readTask("searchSources", args),
|
|
1653
|
+
readFeedback: (args) => readTask("readFeedback", args),
|
|
1654
|
+
verifySubmission: async ({ taskId, actionId }) => {
|
|
1655
|
+
const snapshot = await load(taskId), action = snapshot.state.actions?.find((a) => a.id === actionId);
|
|
1656
|
+
if (!action || !["intent", "unknown"].includes(action.status))
|
|
1657
|
+
return {
|
|
1658
|
+
status: "needsDecision",
|
|
1659
|
+
reason: "verification-requires-original-unknown-or-intent",
|
|
1660
|
+
mayEnd: false,
|
|
1661
|
+
};
|
|
1662
|
+
return (executor ? parallelRound : round)({
|
|
1663
|
+
taskId,
|
|
1664
|
+
requestedKind: action.kind,
|
|
1665
|
+
selector: (a) => a.id === actionId,
|
|
1666
|
+
});
|
|
1667
|
+
},
|
|
1668
|
+
verifyFollow: async (target) => {
|
|
1669
|
+
const mode = target.mode ?? "receipt";
|
|
1670
|
+
if (!["receipt", "decision"].includes(mode))
|
|
1671
|
+
throw new Error("关注核验模式须为receipt/decision");
|
|
1672
|
+
const caps = platform.preflight({ operation: "read" });
|
|
1673
|
+
if (!caps.covered)
|
|
1674
|
+
return {
|
|
1675
|
+
handle: target.handle,
|
|
1676
|
+
verified: false,
|
|
1677
|
+
status: "needsCapability",
|
|
1678
|
+
...caps,
|
|
1679
|
+
};
|
|
1680
|
+
if (!target.url || !target.evidenceRef) {
|
|
1681
|
+
const original = (await store.load()).state.actions?.find((a) => a.kind === "follow" && same(a.handle, target.handle));
|
|
1682
|
+
if (!original)
|
|
1683
|
+
return {
|
|
1684
|
+
handle: target.handle,
|
|
1685
|
+
verified: false,
|
|
1686
|
+
status: "needsDecision",
|
|
1687
|
+
reason: "original-person-observation-required",
|
|
1688
|
+
};
|
|
1689
|
+
target = original;
|
|
1690
|
+
}
|
|
1691
|
+
const value = await readTask("readPerson", {
|
|
1692
|
+
...target,
|
|
1693
|
+
mode: "decision",
|
|
1694
|
+
});
|
|
1695
|
+
return {
|
|
1696
|
+
handle: target.handle,
|
|
1697
|
+
status: value.status,
|
|
1698
|
+
fresh: value.fresh,
|
|
1699
|
+
verified: value.status === "observed" &&
|
|
1700
|
+
!value.needsStateCheck &&
|
|
1701
|
+
!value.alerts?.length &&
|
|
1702
|
+
value.relationship === "following" &&
|
|
1703
|
+
(!target.accountHandle ||
|
|
1704
|
+
same(value.accountHandle, target.accountHandle)),
|
|
1705
|
+
...(value.evidenceRef ? { evidenceRef: value.evidenceRef } : {}),
|
|
1706
|
+
...(value.missing?.length ? { missing: value.missing } : {}),
|
|
1707
|
+
...(value.diagnostic
|
|
1708
|
+
? {
|
|
1709
|
+
diagnostic: {
|
|
1710
|
+
operation: value.diagnostic.operation,
|
|
1711
|
+
category: value.diagnostic.category,
|
|
1712
|
+
code: value.diagnostic.code,
|
|
1713
|
+
},
|
|
1714
|
+
}
|
|
1715
|
+
: {}),
|
|
1716
|
+
...(mode === "decision" ? { observation: value } : {}),
|
|
1717
|
+
};
|
|
1718
|
+
},
|
|
1719
|
+
runGrowthRound: (args) => executor ? parallelRound(args) : round(args),
|
|
1720
|
+
resumeGrowthRound: (args) => executor ? parallelRound(args) : round(args),
|
|
1721
|
+
inspectRound: async ({ taskId }) => {
|
|
1722
|
+
const snapshot = await load(taskId);
|
|
1723
|
+
return result(snapshot);
|
|
1724
|
+
},
|
|
1725
|
+
followOne: ({ taskId, handle }) => (executor ? parallelRound : round)({
|
|
1726
|
+
taskId,
|
|
1727
|
+
requestedKind: "follow",
|
|
1728
|
+
selector: (a) => a.kind === "follow" && same(a.handle, handle),
|
|
1729
|
+
}),
|
|
1730
|
+
followBatch: async ({ taskId, handles, preparedActions } = {}) => {
|
|
1731
|
+
if (!Array.isArray(handles) ||
|
|
1732
|
+
handles.length < 5 ||
|
|
1733
|
+
handles.length > 10 ||
|
|
1734
|
+
handles.some((h) => !/^[A-Za-z0-9_]{1,15}$/.test(h)) ||
|
|
1735
|
+
new Set(handles.map((h) => h.toLowerCase())).size !==
|
|
1736
|
+
handles.length)
|
|
1737
|
+
throw new Error("正常关注子批需5~10位不同的已准备对象");
|
|
1738
|
+
if (preparedActions?.some((a) => a.kind !== "follow" ||
|
|
1739
|
+
!handles.some((h) => same(h, a.handle))))
|
|
1740
|
+
throw new Error("batch-action-target-mismatch");
|
|
1741
|
+
await prepareActionsForCall(taskId, preparedActions);
|
|
1742
|
+
let batchId, resumedBatch = false;
|
|
1743
|
+
const original = await store.transaction(async (snapshot) => {
|
|
1744
|
+
if (!scope(snapshot.state).includes("follow"))
|
|
1745
|
+
throw new Error("kind-outside-round-scope");
|
|
1746
|
+
if (snapshot.state.batchId !== taskId ||
|
|
1747
|
+
handles.some((h) => !snapshot.state.actions?.some((a) => a.kind === "follow" && same(a.handle, h))))
|
|
1748
|
+
throw new Error("子批名单必须归原任务队列");
|
|
1749
|
+
const next = structuredClone(snapshot.state);
|
|
1750
|
+
next.followBatches ??= [];
|
|
1751
|
+
const previous = next.followBatches.findLast((b) => b.status !== "SETTLED" &&
|
|
1752
|
+
b.handles.length === handles.length &&
|
|
1753
|
+
b.handles.every((h, i) => same(h, handles[i])));
|
|
1754
|
+
resumedBatch = Boolean(previous);
|
|
1755
|
+
batchId = previous?.id ?? `${taskId}-FOLLOW-${stamp()}`;
|
|
1756
|
+
if (previous)
|
|
1757
|
+
Object.assign(previous, {
|
|
1758
|
+
status: "RUNNING",
|
|
1759
|
+
lastResumedAt: stamp(),
|
|
1760
|
+
});
|
|
1761
|
+
else
|
|
1762
|
+
next.followBatches.push({
|
|
1763
|
+
id: batchId,
|
|
1764
|
+
handles,
|
|
1765
|
+
planned: handles.length,
|
|
1766
|
+
startedAt: stamp(),
|
|
1767
|
+
status: "RUNNING",
|
|
1768
|
+
});
|
|
1769
|
+
next.updatedAt = stamp();
|
|
1770
|
+
return next;
|
|
1771
|
+
});
|
|
1772
|
+
const preparedCount = original.state.actions.filter((a) => a.kind === "follow" &&
|
|
1773
|
+
a.status === "prepared" &&
|
|
1774
|
+
handles.some((h) => same(h, a.handle))).length;
|
|
1775
|
+
let eligibleAt = clock();
|
|
1776
|
+
const previousGroup = original.state.execution?.followGroup;
|
|
1777
|
+
const lastAttempt = (original.state.attempts ?? [])
|
|
1778
|
+
.filter((a) => a.kind === "follow")
|
|
1779
|
+
.sort((a, b) => Date.parse(a.at) - Date.parse(b.at))
|
|
1780
|
+
.at(-1);
|
|
1781
|
+
const lastAction = original.state.actions.find((a) => a.id === lastAttempt?.actionId);
|
|
1782
|
+
const sameBatchRecovery = resumedBatch &&
|
|
1783
|
+
lastAction?.status === "verified" &&
|
|
1784
|
+
evidenceExists(lastAction.resultEvidence) &&
|
|
1785
|
+
handles.some((h) => same(h, lastAction.handle));
|
|
1786
|
+
if (preparedCount && previousGroup?.attempts && !sameBatchRecovery)
|
|
1787
|
+
eligibleAt = Math.max(eligibleAt, Date.parse(previousGroup.lastAt) + 180000);
|
|
1788
|
+
for (const [duration, limit] of followWindows) {
|
|
1789
|
+
const recent = (original.state.attempts ?? [])
|
|
1790
|
+
.filter((a) => a.kind === "follow" && Date.parse(a.at) > clock() - duration)
|
|
1791
|
+
.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
|
1792
|
+
if (preparedCount > limit - recent.length)
|
|
1793
|
+
eligibleAt = Math.max(eligibleAt, Date.parse(recent[recent.length - limit + preparedCount - 1].at) +
|
|
1794
|
+
duration);
|
|
1795
|
+
}
|
|
1796
|
+
if (eligibleAt > clock()) {
|
|
1797
|
+
const resumeAt = new Date(eligibleAt).toISOString();
|
|
1798
|
+
await store.transaction((snapshot) => {
|
|
1799
|
+
const next = structuredClone(snapshot.state);
|
|
1800
|
+
Object.assign(next.followBatches.find((b) => b.id === batchId), { status: "WAITING", remaining: preparedCount, resumeAt });
|
|
1801
|
+
next.updatedAt = stamp();
|
|
1802
|
+
return next;
|
|
1803
|
+
});
|
|
1804
|
+
return {
|
|
1805
|
+
taskId,
|
|
1806
|
+
batchId,
|
|
1807
|
+
planned: handles.length,
|
|
1808
|
+
attempted: 0,
|
|
1809
|
+
verifiedThisCall: 0,
|
|
1810
|
+
unknownInBatch: original.state.actions.filter((a) => handles.some((h) => same(h, a.handle)) &&
|
|
1811
|
+
["unknown", "intent"].includes(a.status)).length,
|
|
1812
|
+
remaining: preparedCount,
|
|
1813
|
+
mayEnd: false,
|
|
1814
|
+
decision: [
|
|
1815
|
+
{
|
|
1816
|
+
kind: "follow",
|
|
1817
|
+
status: "waiting",
|
|
1818
|
+
reason: "normal-batch-capacity-or-cooldown",
|
|
1819
|
+
resumeAt,
|
|
1820
|
+
},
|
|
1821
|
+
],
|
|
1822
|
+
evidenceRef: store.path,
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
let result;
|
|
1826
|
+
const exceptional = [];
|
|
1827
|
+
for (const handle of handles) {
|
|
1828
|
+
// A resumed batch submits only its remaining prepared members. Recovery
|
|
1829
|
+
// stays in the read-only API; a frozen member must not disturb that path.
|
|
1830
|
+
if (!original.state.actions.some((a) => a.kind === "follow" &&
|
|
1831
|
+
same(a.handle, handle) &&
|
|
1832
|
+
a.status === "prepared"))
|
|
1833
|
+
continue;
|
|
1834
|
+
result = await (executor ? parallelRound : round)({
|
|
1835
|
+
taskId,
|
|
1836
|
+
requestedKind: "follow",
|
|
1837
|
+
selector: (a) => a.kind === "follow" && same(a.handle, handle),
|
|
1838
|
+
});
|
|
1839
|
+
const relevant = (result.decision ?? []).filter((d) => d.kind === "follow" ||
|
|
1840
|
+
["harness-boundary-error", "original-ledger-conflict"].includes(d.reason) ||
|
|
1841
|
+
original.state.actions.some((a) => a.id === d.actionId && a.kind === "follow"));
|
|
1842
|
+
exceptional.push(...relevant);
|
|
1843
|
+
// A new unconfirmed submission is a diagnostic boundary, not permission
|
|
1844
|
+
// to consume the rest of the batch on the same failing write path.
|
|
1845
|
+
if (relevant.length)
|
|
1846
|
+
break;
|
|
1847
|
+
}
|
|
1848
|
+
const after = await store.load();
|
|
1849
|
+
const summary = {
|
|
1850
|
+
taskId,
|
|
1851
|
+
batchId,
|
|
1852
|
+
planned: handles.length,
|
|
1853
|
+
attempted: (after.state.attempts ?? [])
|
|
1854
|
+
.slice(original.state.attempts?.length ?? 0)
|
|
1855
|
+
.filter((a) => a.kind === "follow" &&
|
|
1856
|
+
handles.some((h) => same(after.state.actions.find((x) => x.id === a.actionId)
|
|
1857
|
+
?.handle, h))).length,
|
|
1858
|
+
verifiedThisCall: after.state.actions.filter((a) => a.kind === "follow" &&
|
|
1859
|
+
a.status === "verified" &&
|
|
1860
|
+
handles.some((h) => same(h, a.handle)) &&
|
|
1861
|
+
original.state.actions.find((old) => old.id === a.id)
|
|
1862
|
+
?.status !== "verified").length,
|
|
1863
|
+
unknownInBatch: after.state.actions.filter((a) => a.kind === "follow" &&
|
|
1864
|
+
handles.some((h) => same(a.handle, h)) &&
|
|
1865
|
+
["unknown", "intent"].includes(a.status)).length,
|
|
1866
|
+
remaining: after.state.actions.filter((a) => a.kind === "follow" &&
|
|
1867
|
+
handles.some((h) => same(a.handle, h)) &&
|
|
1868
|
+
a.status === "prepared").length,
|
|
1869
|
+
actual: result?.actual,
|
|
1870
|
+
decision: exceptional,
|
|
1871
|
+
mayEnd: result?.mayEnd ?? false,
|
|
1872
|
+
evidenceRef: store.path,
|
|
1873
|
+
};
|
|
1874
|
+
await store.transaction((snapshot) => {
|
|
1875
|
+
const next = structuredClone(snapshot.state), batch = next.followBatches.find((b) => b.id === batchId);
|
|
1876
|
+
Object.assign(batch, {
|
|
1877
|
+
attempted: (batch.attempted ?? 0) + summary.attempted,
|
|
1878
|
+
verifiedTotal: (batch.verifiedTotal ?? 0) + summary.verifiedThisCall,
|
|
1879
|
+
verifiedThisCall: summary.verifiedThisCall,
|
|
1880
|
+
unknown: summary.unknownInBatch,
|
|
1881
|
+
remaining: summary.remaining,
|
|
1882
|
+
finishedAt: stamp(),
|
|
1883
|
+
status: summary.remaining || summary.unknownInBatch ? "PARTIAL" : "SETTLED",
|
|
1884
|
+
});
|
|
1885
|
+
next.updatedAt = stamp();
|
|
1886
|
+
return next;
|
|
1887
|
+
});
|
|
1888
|
+
return summary;
|
|
1889
|
+
},
|
|
1890
|
+
publishPost: async (input) => {
|
|
1891
|
+
if (!executor && input.independentExecution)
|
|
1892
|
+
return publishIndependent(input);
|
|
1893
|
+
if (input.preparedAction &&
|
|
1894
|
+
(input.preparedAction.kind !== "original" ||
|
|
1895
|
+
input.preparedAction.draftPath !== input.draftPath))
|
|
1896
|
+
throw new Error("prepared-original-target-mismatch");
|
|
1897
|
+
await prepareActionsForCall(input.taskId, input.preparedAction ? [input.preparedAction] : []);
|
|
1898
|
+
return (executor ? parallelRound : round)({
|
|
1899
|
+
taskId: input.taskId,
|
|
1900
|
+
requestedKind: "original",
|
|
1901
|
+
selector: (a) => a.kind === "original" && a.draftPath === input.draftPath,
|
|
1902
|
+
});
|
|
1903
|
+
},
|
|
1904
|
+
replyToPost: async ({ taskId, postId, text, preparedAction }) => {
|
|
1905
|
+
if (preparedAction &&
|
|
1906
|
+
(preparedAction.kind !== "reply" ||
|
|
1907
|
+
preparedAction.postId !== postId ||
|
|
1908
|
+
preparedAction.text !== text))
|
|
1909
|
+
throw new Error("prepared-reply-target-mismatch");
|
|
1910
|
+
await prepareActionsForCall(taskId, preparedAction ? [preparedAction] : []);
|
|
1911
|
+
return (executor ? parallelRound : round)({
|
|
1912
|
+
taskId,
|
|
1913
|
+
requestedKind: "reply",
|
|
1914
|
+
selector: (a) => a.kind === "reply" && a.postId === postId && a.text === text,
|
|
1915
|
+
});
|
|
1916
|
+
},
|
|
1917
|
+
};
|
|
1918
|
+
}
|