@zq-silk/yui 1.0.0 → 1.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/README.md +27 -9
- package/dist/cli/commandCatalog.js +7 -5
- package/dist/cli/invocationAuthority.js +4 -0
- package/dist/cli.js +130 -5
- package/dist/commands/releaseCommands.js +33 -24
- package/dist/commands/taskCommands.js +18 -28
- package/dist/commands/taskFactCommands.js +6 -2
- package/dist/controller/clientRuntime.js +7 -2
- package/dist/controller/domainIdentity.js +7 -7
- package/dist/controller/handoverCandidate.js +2 -5
- package/dist/controller/jobSupervisor.js +4 -4
- package/dist/controller/resourceCleanupLinux.js +35 -19
- package/dist/controller/resourceInventoryLinux.js +104 -7
- package/dist/controller/runtime.js +4 -13
- package/dist/controller/runtimeLaunchCoordinator.js +2 -4
- package/dist/controller/sessionOwnerReconciliation.js +40 -16
- package/dist/core/controllerClient.js +44 -16
- package/dist/core/controllerEndpoint.js +4 -3
- package/dist/core/controllerProcessIdentity.js +54 -0
- package/dist/core/controllerServer.js +16 -11
- package/dist/core/fileLockOwner.js +53 -12
- package/dist/doctor/doctor.js +230 -12
- package/dist/execution/workItemMainRun.js +1 -1
- package/dist/executor/agentAdapter.js +0 -8
- package/dist/executor/agentExecutor.js +4 -1
- package/dist/executor/fileRoleLaunchPlanner.js +15 -25
- package/dist/message/messageContinuation.js +1 -1
- package/dist/release/releaseHandover.js +77 -25
- package/dist/release/runtimeRelease.js +7 -7
- package/dist/repository/gitWorkspace.js +9 -8
- package/dist/repository/taskWorkspacePreparer.js +15 -57
- package/dist/resources/liveReferences.js +64 -7
- package/dist/runtime/agentEndpointIdentity.js +4 -1
- package/dist/runtime/agentHost.js +13 -9
- package/dist/runtime/codexInteractiveHost.js +1 -1
- package/dist/runtime/codexSharedDaemon.js +211 -0
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/jsonLineChannel.js +6 -1
- package/dist/runtime/localStartup.js +65 -0
- package/dist/runtime/native/darwin-arm64/claude-process-owner +0 -0
- package/dist/runtime/native/darwin-arm64/process-identity +0 -0
- package/dist/runtime/native/darwin-x64/claude-process-owner +0 -0
- package/dist/runtime/native/darwin-x64/process-identity +0 -0
- package/dist/runtime/{claude-process-owner → native/linux-x64/claude-process-owner} +0 -0
- package/dist/runtime/nativeExecutable.js +10 -0
- package/dist/runtime/runtimeCoherence.js +10 -3
- package/dist/runtime/sessionOwnerIdentity.js +19 -0
- package/dist/runtime/sessionTerminationGuard.js +10 -3
- package/dist/runtime/sessionTitle.js +15 -0
- package/dist/runtime/structuredProviderHost.js +94 -29
- package/dist/scheduler/activeRoleRunDelivery.js +2 -11
- package/dist/scheduler/leaderWakeupProcessor.js +1 -2
- package/dist/scheduler/ports.js +1 -8
- package/dist/scheduler/taskExecutionProjection.js +1 -6
- package/dist/storage/sqliteSchema.js +15 -3
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/taskCatalog.js +10 -2
- package/dist/storage/upgrade/taskMainWorkspaceMigration.js +83 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +25 -6
- package/dist/task/task.js +0 -12
- package/dist/tmux/tmuxManager.js +8 -2
- package/dist/tmux/tmuxSocketEndpoint.js +23 -1
- package/dist/web/assets/client/app.js +131 -11
- package/dist/web/assets/client/i18n.js +18 -0
- package/dist/web/assets/client/taskSummary.js +7 -2
- package/dist/web/assets/client/taskSurface.js +54 -5
- package/dist/web/assets/client/view.js +7 -2
- package/dist/web/assets/shell.js +21 -11
- package/dist/web/assets/styles/layout.js +33 -6
- package/dist/web/assets/styles/responsive.js +18 -5
- package/dist/web/assets/styles/tokens.js +1 -1
- package/dist/web/assets/styles/widgets.js +5 -0
- package/docs/managed-turn-and-session-runtime.md +7 -0
- package/docs/managed-turn-and-session-runtime.zh-CN.md +5 -0
- package/docs/provider-runtime.md +7 -0
- package/docs/provider-runtime.zh-CN.md +5 -0
- package/docs/release-workflow.md +7 -1
- package/docs/task-delivery.md +5 -4
- package/docs/testing/verification-levels.md +9 -7
- package/i18n/README.zh-CN.md +19 -6
- package/package.json +5 -6
- package/skills/yui-leader/SKILL.md +8 -0
- package/skills/yui-leader/references/execution.md +4 -1
- package/skills/yui-operator/SKILL.md +35 -15
|
@@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { isDeepStrictEqual } from "node:util";
|
|
7
7
|
import { runPurposeAdmitsTaskState } from "../agentRun/agentRun.js";
|
|
8
8
|
import { assertExecutionEnvironmentCurrent } from "../runtime/executionEnvironment.js";
|
|
9
|
-
import { taskOwnsManagedWorkspace } from "../task/task.js";
|
|
10
9
|
import { configuredAgentToDefinition, resolveAgentEnvironment } from "../agent/agent.js";
|
|
11
10
|
import { NATIVE_AGENT_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, operationalAgentEnvironment, selectEnvironment } from "../agent/launchEnvironment.js";
|
|
12
11
|
import { compileRoleSessionContext, roleSessionKind } from "../context/roleSessionContext.js";
|
|
@@ -17,7 +16,7 @@ import { managedRuntimeAdmission } from "../runtime/agentDriver.js";
|
|
|
17
16
|
import { builtinAgentEndpointImplementation, validateAgentEndpointImplementation } from "../runtime/agentEndpointIdentity.js";
|
|
18
17
|
import { builtinAgentDriverRegistry, builtinDriverIdForAdapter } from "../runtime/builtinAgentDrivers.js";
|
|
19
18
|
import { assertProviderConversationReplaceable } from "../runtime/providerRuntimeIdentity.js";
|
|
20
|
-
import { resolveTaskRoleSessionTitle } from "../runtime/sessionTitle.js";
|
|
19
|
+
import { operatorSessionTitle, resolveTaskRoleSessionTitle } from "../runtime/sessionTitle.js";
|
|
21
20
|
import { parseTaskRuntimeIsolationDescriptor, taskRuntimeIsolationEnvironment } from "../runtime/taskRuntimeIsolation.js";
|
|
22
21
|
import { planningRuntimeCwd } from "../storage/homeLayout.js";
|
|
23
22
|
import { resolveAgentAdapter } from "./agentAdapter.js";
|
|
@@ -33,6 +32,7 @@ export class FileRoleLaunchPlanner {
|
|
|
33
32
|
#agentEnvironment;
|
|
34
33
|
#nativeAgentEnvironment;
|
|
35
34
|
#createNativeSessionId;
|
|
35
|
+
#now;
|
|
36
36
|
#cliPath;
|
|
37
37
|
#inspectWorkspacePhysicalState;
|
|
38
38
|
#entryPoint;
|
|
@@ -51,6 +51,7 @@ export class FileRoleLaunchPlanner {
|
|
|
51
51
|
this.#agentEnvironment = this.#selectConfiguredAgentEnvironment(sourceEnvironment);
|
|
52
52
|
this.#nativeAgentEnvironment = this.#selectConfiguredNativeEnvironment(sourceEnvironment);
|
|
53
53
|
this.#createNativeSessionId = options.createNativeSessionId ?? randomUUID;
|
|
54
|
+
this.#now = options.now ?? (() => new Date());
|
|
54
55
|
this.#inspectWorkspacePhysicalState = options.inspectWorkspacePhysicalState
|
|
55
56
|
?? inspectWorkspacePhysicalState;
|
|
56
57
|
this.#cliPath = canonicalPath(options.cliPath
|
|
@@ -112,30 +113,14 @@ export class FileRoleLaunchPlanner {
|
|
|
112
113
|
if (!runPurposeAdmitsTaskState(purpose, task)) {
|
|
113
114
|
throw new Error(`Task execution is not enabled: ${input.taskId}.`);
|
|
114
115
|
}
|
|
115
|
-
//
|
|
116
|
-
// conversation, and a Task activated with an empty environment plan. Both
|
|
117
|
-
// run with no Project entries, so there is nothing to preflight and no
|
|
118
|
-
// worktree to wait for. Every other fence — live Session, replaceable
|
|
119
|
-
// Conversation, configured Agent, Context protocol identity, adopted
|
|
120
|
-
// execution environment — still applies.
|
|
116
|
+
// Draft planning has no delivery workspace. Active Tasks always have one.
|
|
121
117
|
const planningDraft = purpose === "planning" && task.status === "draft";
|
|
122
118
|
// A planning cwd is a disposable per-Task runtime resource, not a delivery
|
|
123
119
|
// workspace. Materialize only the exact directory selected at creation.
|
|
124
120
|
if (planningDraft && resolve(role.workspace) === resolve(planningRuntimeCwd(this.home, task.id))) {
|
|
125
121
|
mkdirSync(role.workspace, { recursive: true, mode: 0o700 });
|
|
126
122
|
}
|
|
127
|
-
const workspaceFree = planningDraft
|
|
128
|
-
// An empty resource plan is a legal Task shape, but it does not make a
|
|
129
|
-
// shared directory a legal cwd. Once such a Task is active it can name the
|
|
130
|
-
// directory it means through the existing environment plan, so require that
|
|
131
|
-
// instead of silently running in whatever workspace the Role inherited with
|
|
132
|
-
// isolation skipped.
|
|
133
|
-
if (workspaceFree && !planningDraft && role.executionEnvironment === undefined) {
|
|
134
|
-
throw new Error(`Task ${task.id} owns no workspace and no adopted execution environment, so Role `
|
|
135
|
-
+ `${role.name} has no directory of its own to run in. Request activation with a `
|
|
136
|
-
+ "`scratch` or `local` environment plan, or bind an adopted environment with "
|
|
137
|
-
+ "`environment.bind`, instead of inheriting a shared workspace.");
|
|
138
|
-
}
|
|
123
|
+
const workspaceFree = planningDraft;
|
|
139
124
|
const runWorkspace = activeRun?.workspace;
|
|
140
125
|
const main = this.store.getTaskWorkspace(task.id);
|
|
141
126
|
// Quick Win (EXE-04/EXE-08): classify workspace preflight failures so
|
|
@@ -259,7 +244,9 @@ export class FileRoleLaunchPlanner {
|
|
|
259
244
|
if (input.mode === "new" && sessionSet?.providerBinding != null) {
|
|
260
245
|
assertProviderConversationReplaceable(sessionSet.providerBinding);
|
|
261
246
|
}
|
|
262
|
-
return this.#compile(role, input, { scope: "global" },
|
|
247
|
+
return this.#compile(role, input, { scope: "global" }, input.mode === "new" && role.name === "operator"
|
|
248
|
+
? operatorSessionTitle(this.#now(), this.store.getConfig().timeZone)
|
|
249
|
+
: undefined, input.mode === "resume" && compatibleExisting ? existing.nativeSessionId : undefined, undefined, effective, { purpose: "execution" });
|
|
263
250
|
}
|
|
264
251
|
#compile(role, input, owner, sessionTitle, knownNativeSessionId, workspaceOverride, effective, sessionPolicy) {
|
|
265
252
|
const launchRole = effectiveRoleForLaunch(role, effective);
|
|
@@ -389,11 +376,16 @@ export class FileRoleLaunchPlanner {
|
|
|
389
376
|
const effectiveConfig = withNativeProjectDirectories(roleConfig, effective.executionEnvironment === undefined
|
|
390
377
|
? nativeAdditionalDirectories(effective.workspace, agentWorkspace)
|
|
391
378
|
: []);
|
|
379
|
+
const postCreateOperatorTitle = owner.scope === "global"
|
|
380
|
+
&& role.name === "operator"
|
|
381
|
+
&& sessionTitle !== undefined;
|
|
392
382
|
const compileInput = {
|
|
393
383
|
agent,
|
|
394
384
|
config: effectiveConfig,
|
|
395
385
|
workspace: agentWorkspace,
|
|
396
|
-
...(sessionTitle === undefined
|
|
386
|
+
...(sessionTitle === undefined || postCreateOperatorTitle
|
|
387
|
+
? {}
|
|
388
|
+
: { sessionTitle }),
|
|
397
389
|
...sessionContext
|
|
398
390
|
};
|
|
399
391
|
if (input.mode === "resume"
|
|
@@ -685,9 +677,7 @@ export class FileRoleLaunchPlanner {
|
|
|
685
677
|
}
|
|
686
678
|
}
|
|
687
679
|
export function nativeAgentWorkspace(workspace) {
|
|
688
|
-
return workspace.
|
|
689
|
-
? workspace.entries[0].path
|
|
690
|
-
: workspace.root;
|
|
680
|
+
return workspace.root;
|
|
691
681
|
}
|
|
692
682
|
export function nativeAdditionalDirectories(workspace, agentWorkspace) {
|
|
693
683
|
return [workspace.root, ...workspace.entries.map(({ path }) => path)]
|
|
@@ -56,7 +56,7 @@ export function messageContinuationBlocker(store, message) {
|
|
|
56
56
|
if (target.reviewRoundId === undefined && !isDeepStrictEqual([...work.writeProjectIds].sort(), [...owner.effective.writeProjectIds].sort()))
|
|
57
57
|
return "assignment-scope-changed";
|
|
58
58
|
if (target.reviewRoundId === undefined && owner.workspace !== undefined) {
|
|
59
|
-
const workspace = target.roleName === "leader"
|
|
59
|
+
const workspace = target.roleName === "leader" || task.projectBindings.length === 0
|
|
60
60
|
? store.getTaskWorkspace(message.taskId) : store.getWorkItemWorkspace(message.taskId, target.workItemId);
|
|
61
61
|
const identity = (entries) => entries.map((entry) => ({
|
|
62
62
|
projectId: entry.projectId, path: entry.path, access: entry.access, branch: entry.branch
|
|
@@ -13,11 +13,20 @@
|
|
|
13
13
|
* the candidate read-only and reports dual-owner; a crashed activator resumes
|
|
14
14
|
* from the recorded phase.
|
|
15
15
|
*/
|
|
16
|
+
import { processGenerationIsLive } from "../core/fileLockOwner.js";
|
|
16
17
|
import { RELEASE_HANDOVER_PROMOTION_TIMEOUT_MS } from "../runtime/runtimeDeadlines.js";
|
|
17
|
-
import { acquireHandoverLock, isOwnerLive, newHandoverId, readActiveReleasePointer, readCandidateDiscovery, readHandoverFence, readHandoverReceipt, readRuntimeIdentity, removeCandidateDiscovery, removeHandoverFence, writeActiveReleasePointer, writeHandoverFence, writeHandoverReceipt } from "./runtimeRelease.js";
|
|
18
|
+
import { acquireHandoverLock, isOwnerLive, newHandoverId, readActiveReleasePointer, readCandidateDiscovery, readHandoverFence, readHandoverReceipt, readRuntimeIdentity, removeActiveReleasePointer, removeCandidateDiscovery, removeHandoverFence, writeActiveReleasePointer, writeHandoverFence, writeHandoverReceipt } from "./runtimeRelease.js";
|
|
18
19
|
export const DEFAULT_CANDIDATE_READY_TIMEOUT_MS = 30_000;
|
|
19
20
|
export const DEFAULT_PROMOTION_TIMEOUT_MS = RELEASE_HANDOVER_PROMOTION_TIMEOUT_MS;
|
|
20
21
|
export const DEFAULT_POLL_INTERVAL_MS = 100;
|
|
22
|
+
export class ControllerStartUnconfirmedError extends Error {
|
|
23
|
+
pid;
|
|
24
|
+
constructor(pid, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.pid = pid;
|
|
27
|
+
this.name = "ControllerStartUnconfirmedError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
21
30
|
/**
|
|
22
31
|
* Optional confirmation debounce after the candidate latches `dualOwner:
|
|
23
32
|
* true`. Defaults to 0: the candidate's own exit grace
|
|
@@ -147,13 +156,17 @@ async function activateLocked(ports, locked) {
|
|
|
147
156
|
ports.spawnCandidate(home, releaseDir, handoverId);
|
|
148
157
|
const candidateReady = await waitForCandidateReady(ports, home, manifest, locked.candidateReadyTimeoutMs, locked.pollIntervalMs);
|
|
149
158
|
if (candidateReady === null) {
|
|
150
|
-
await rollbackHandover(ports, home, fence
|
|
159
|
+
const rollbackError = await rollbackHandover(ports, home, fence);
|
|
151
160
|
return {
|
|
152
161
|
outcome: "aborted",
|
|
153
162
|
phase: "candidate-ready",
|
|
154
|
-
message:
|
|
155
|
-
|
|
156
|
-
|
|
163
|
+
message: rollbackError === null
|
|
164
|
+
? "The new Controller candidate did not become ready in time."
|
|
165
|
+
: `The new Controller candidate did not become ready; rollback is unconfirmed: ${rollbackError}`,
|
|
166
|
+
action: rollbackError === null
|
|
167
|
+
? "The old Controller has resumed accepting mutations; retry the activation."
|
|
168
|
+
: "Preserve the handover fence and candidate discovery; inspect process owners before retrying.",
|
|
169
|
+
recoverable: rollbackError === null
|
|
157
170
|
};
|
|
158
171
|
}
|
|
159
172
|
// 6) Switch the active release pointer. This is the atomic commit point:
|
|
@@ -221,10 +234,21 @@ async function activateWithoutOldController(ports, home, manifest, releaseDir, r
|
|
|
221
234
|
await ports.startControllerFromRelease(home, releaseDir);
|
|
222
235
|
}
|
|
223
236
|
catch (error) {
|
|
224
|
-
|
|
225
|
-
|
|
237
|
+
if (error instanceof ControllerStartUnconfirmedError) {
|
|
238
|
+
return {
|
|
239
|
+
outcome: "aborted",
|
|
240
|
+
phase: "start-controller",
|
|
241
|
+
message: `New Controller startup is unconfirmed (PID ${error.pid}): ${error.message}`,
|
|
242
|
+
action: "Preserve the new release pointer and inspect the exact Controller process before retrying.",
|
|
243
|
+
recoverable: false
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
// A pre-spawn failure cannot have started a Controller. Restore the
|
|
247
|
+
// pointer's previous value, including its absence.
|
|
226
248
|
if (active !== null)
|
|
227
249
|
writeActiveReleasePointer(home, active);
|
|
250
|
+
else
|
|
251
|
+
removeActiveReleasePointer(home);
|
|
228
252
|
return {
|
|
229
253
|
outcome: "aborted",
|
|
230
254
|
phase: "start-controller",
|
|
@@ -252,7 +276,7 @@ async function readOldController(ports, home) {
|
|
|
252
276
|
if (typeof identity !== "object"
|
|
253
277
|
|| identity === null
|
|
254
278
|
|| typeof identity.pid !== "number") {
|
|
255
|
-
|
|
279
|
+
throw new Error("Running Controller returned an invalid identity; handover cannot be fenced.");
|
|
256
280
|
}
|
|
257
281
|
const record = identity;
|
|
258
282
|
if (record.releaseDrifted === true) {
|
|
@@ -262,8 +286,9 @@ async function readOldController(ports, home) {
|
|
|
262
286
|
const startIdentity = typeof record.processStartIdentity === "string"
|
|
263
287
|
? record.processStartIdentity
|
|
264
288
|
: undefined;
|
|
265
|
-
if (startIdentity === undefined)
|
|
266
|
-
|
|
289
|
+
if (startIdentity === undefined) {
|
|
290
|
+
throw new Error("Running Controller did not provide a process-generation identity; handover cannot be fenced.");
|
|
291
|
+
}
|
|
267
292
|
return Object.freeze({
|
|
268
293
|
pid: record.pid,
|
|
269
294
|
processStartIdentity: startIdentity,
|
|
@@ -327,19 +352,29 @@ async function waitForPromotion(ports, home, fence, timeoutMs, pollIntervalMs, d
|
|
|
327
352
|
await ports.sleep(pollIntervalMs);
|
|
328
353
|
}
|
|
329
354
|
}
|
|
330
|
-
async function rollbackHandover(ports, home, fence
|
|
355
|
+
async function rollbackHandover(ports, home, fence) {
|
|
331
356
|
const candidate = readCandidateDiscovery(home);
|
|
332
357
|
if (candidate !== null) {
|
|
333
358
|
try {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
359
|
+
if (processGenerationIsLive(candidate.pid, candidate.processStartIdentity)) {
|
|
360
|
+
ports.killOwnedProcess({
|
|
361
|
+
pid: candidate.pid,
|
|
362
|
+
processStartIdentity: candidate.processStartIdentity,
|
|
363
|
+
buildId: "candidate",
|
|
364
|
+
version: "candidate"
|
|
365
|
+
});
|
|
366
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
367
|
+
if (!processGenerationIsLive(candidate.pid, candidate.processStartIdentity))
|
|
368
|
+
break;
|
|
369
|
+
await ports.sleep(50);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (processGenerationIsLive(candidate.pid, candidate.processStartIdentity)) {
|
|
373
|
+
return `candidate PID ${candidate.pid} is still live`;
|
|
374
|
+
}
|
|
340
375
|
}
|
|
341
|
-
catch {
|
|
342
|
-
|
|
376
|
+
catch (error) {
|
|
377
|
+
return `candidate PID ${candidate.pid}: ${messageOf(error)}`;
|
|
343
378
|
}
|
|
344
379
|
removeCandidateDiscovery(home);
|
|
345
380
|
}
|
|
@@ -348,8 +383,8 @@ async function rollbackHandover(ports, home, fence, reason) {
|
|
|
348
383
|
handoverId: fence.handoverId
|
|
349
384
|
});
|
|
350
385
|
}
|
|
351
|
-
catch {
|
|
352
|
-
|
|
386
|
+
catch (error) {
|
|
387
|
+
return `old Controller rollback: ${messageOf(error)}`;
|
|
353
388
|
}
|
|
354
389
|
writeHandoverFence(home, Object.freeze({
|
|
355
390
|
...fence,
|
|
@@ -367,7 +402,7 @@ async function rollbackHandover(ports, home, fence, reason) {
|
|
|
367
402
|
startedAt: fence.createdAt,
|
|
368
403
|
completedAt: new Date().toISOString()
|
|
369
404
|
}));
|
|
370
|
-
|
|
405
|
+
return null;
|
|
371
406
|
}
|
|
372
407
|
async function recoverInterruptedHandover(ports, home, locked) {
|
|
373
408
|
const fence = readHandoverFence(home);
|
|
@@ -433,7 +468,15 @@ async function recoverInterruptedHandover(ports, home, locked) {
|
|
|
433
468
|
// candidate-ready wait.
|
|
434
469
|
const candidateReady = await waitForCandidateReady(ports, home, locked.manifest, locked.candidateReadyTimeoutMs, locked.pollIntervalMs);
|
|
435
470
|
if (candidateReady === null) {
|
|
436
|
-
await rollbackHandover(ports, home, fence
|
|
471
|
+
const rollbackError = await rollbackHandover(ports, home, fence);
|
|
472
|
+
if (rollbackError !== null) {
|
|
473
|
+
return {
|
|
474
|
+
outcome: "aborted", phase: "recovery",
|
|
475
|
+
message: `Interrupted handover rollback is unconfirmed: ${rollbackError}`,
|
|
476
|
+
action: "Preserve the handover fence and candidate discovery; inspect process owners before retrying.",
|
|
477
|
+
recoverable: false
|
|
478
|
+
};
|
|
479
|
+
}
|
|
437
480
|
return null;
|
|
438
481
|
}
|
|
439
482
|
const pointer = Object.freeze({
|
|
@@ -472,10 +515,19 @@ async function recoverInterruptedHandover(ports, home, locked) {
|
|
|
472
515
|
handoverId: fence.handoverId
|
|
473
516
|
});
|
|
474
517
|
}
|
|
475
|
-
catch {
|
|
476
|
-
|
|
518
|
+
catch (error) {
|
|
519
|
+
return {
|
|
520
|
+
outcome: "aborted", phase: "recovery",
|
|
521
|
+
message: `Old Controller rollback is unconfirmed: ${messageOf(error)}`,
|
|
522
|
+
action: "Preserve the handover fence; inspect the old Controller before retrying.",
|
|
523
|
+
recoverable: false
|
|
524
|
+
};
|
|
477
525
|
}
|
|
478
526
|
}
|
|
527
|
+
// The exact candidate generation is gone; only now can its stale
|
|
528
|
+
// discovery be removed during interrupted-handover recovery.
|
|
529
|
+
if (readCandidateDiscovery(home) !== null)
|
|
530
|
+
removeCandidateDiscovery(home);
|
|
479
531
|
removeHandoverFence(home);
|
|
480
532
|
return null;
|
|
481
533
|
}
|
|
@@ -18,8 +18,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
|
18
18
|
import { isStorageVersion, compareStorageVersions } from "../storage/storageVersions.js";
|
|
19
19
|
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
|
|
20
20
|
import { dirname, join, resolve } from "node:path";
|
|
21
|
-
import {
|
|
22
|
-
import { processGenerationIsLive } from "../core/fileLockOwner.js";
|
|
21
|
+
import { currentProcessStartIdentity, processGenerationIsLive } from "../core/fileLockOwner.js";
|
|
23
22
|
import { writeTextFileAtomically } from "../storage/durableFile.js";
|
|
24
23
|
export const RELEASES_DIRECTORY = "runtime/releases";
|
|
25
24
|
export const ACTIVE_RELEASE_POINTER_PATH = "runtime/active-release.json";
|
|
@@ -132,6 +131,10 @@ export function writeActiveReleasePointer(home, pointer) {
|
|
|
132
131
|
validatePointer(pointer);
|
|
133
132
|
writeTextFileAtomically(join(resolve(home), ACTIVE_RELEASE_POINTER_PATH), `${JSON.stringify(pointer, null, 2)}\n`);
|
|
134
133
|
}
|
|
134
|
+
/** Restore the absence of a pointer after a failed first activation. */
|
|
135
|
+
export function removeActiveReleasePointer(home) {
|
|
136
|
+
rmSync(join(resolve(home), ACTIVE_RELEASE_POINTER_PATH), { force: true });
|
|
137
|
+
}
|
|
135
138
|
/**
|
|
136
139
|
* Resolves the Home's active release. Returns null when no pointer exists
|
|
137
140
|
* (no immutable release selected). Fails closed when the pointer names a release that is
|
|
@@ -227,7 +230,7 @@ export function writeHandoverReceipt(home, receipt) {
|
|
|
227
230
|
}
|
|
228
231
|
/** True when the exact process (PID + start identity) is still alive. */
|
|
229
232
|
export function isOwnerLive(owner) {
|
|
230
|
-
return
|
|
233
|
+
return processGenerationIsLive(owner.pid, owner.processStartIdentity);
|
|
231
234
|
}
|
|
232
235
|
export function readCandidateDiscovery(home) {
|
|
233
236
|
const candidatePath = join(resolve(home), CANDIDATE_DISCOVERY_PATH);
|
|
@@ -292,10 +295,7 @@ export function isForeignHandoverLockHeld(home, allowedOwnerPid = process.pid) {
|
|
|
292
295
|
export function acquireHandoverLock(home) {
|
|
293
296
|
const lockPath = join(resolve(home), "runtime", "handover.lock");
|
|
294
297
|
mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
295
|
-
const startIdentity =
|
|
296
|
-
if (startIdentity === undefined) {
|
|
297
|
-
throw new Error(`Cannot read Linux process start identity for activator PID ${process.pid}.`);
|
|
298
|
-
}
|
|
298
|
+
const startIdentity = currentProcessStartIdentity();
|
|
299
299
|
const owner = Object.freeze({
|
|
300
300
|
pid: process.pid,
|
|
301
301
|
processStartIdentity: startIdentity,
|
|
@@ -821,7 +821,7 @@ export class NodeGitWorkspace {
|
|
|
821
821
|
const state = await this.inspectWorktree(input);
|
|
822
822
|
if (state === "dirty")
|
|
823
823
|
return state;
|
|
824
|
-
const container =
|
|
824
|
+
const container = await canonicalContainer(input.container, false);
|
|
825
825
|
const branch = worktreeIdentity(input.taskSegment, input.roleName).branch;
|
|
826
826
|
const path = managedPath(container, input.directory);
|
|
827
827
|
const project = await this.inspect(input.repositoryPath);
|
|
@@ -970,7 +970,7 @@ export class NodeGitWorkspace {
|
|
|
970
970
|
return "removed";
|
|
971
971
|
}
|
|
972
972
|
async inspectWorktree(input) {
|
|
973
|
-
const container =
|
|
973
|
+
const container = await canonicalContainer(input.container, false);
|
|
974
974
|
const path = managedPath(container, input.directory);
|
|
975
975
|
const kind = await pathKind(path);
|
|
976
976
|
const branch = input.expectedBranch ?? worktreeIdentity(input.taskSegment, input.roleName).branch;
|
|
@@ -1000,7 +1000,7 @@ export class NodeGitWorkspace {
|
|
|
1000
1000
|
});
|
|
1001
1001
|
}
|
|
1002
1002
|
async #removeManagedWorktree(input) {
|
|
1003
|
-
const container =
|
|
1003
|
+
const container = await canonicalContainer(input.container, false);
|
|
1004
1004
|
const path = managedPath(container, input.identity.directory);
|
|
1005
1005
|
const kind = await pathKind(path);
|
|
1006
1006
|
if (kind === "symlink")
|
|
@@ -1136,7 +1136,7 @@ async function assertOwnedWorktree(project, container, path) {
|
|
|
1136
1136
|
}
|
|
1137
1137
|
}
|
|
1138
1138
|
async function inspectExactRecordedWorktree(input) {
|
|
1139
|
-
const container =
|
|
1139
|
+
const container = await canonicalContainer(input.container, false);
|
|
1140
1140
|
const expectedBranch = worktreeIdentity(input.taskSegment, input.roleName).branch;
|
|
1141
1141
|
const expectedPath = managedPath(container, input.directory);
|
|
1142
1142
|
if (resolve(input.path) !== expectedPath || input.branch !== expectedBranch) {
|
|
@@ -1231,10 +1231,11 @@ async function canonicalContainer(path, create) {
|
|
|
1231
1231
|
const lexical = resolve(path);
|
|
1232
1232
|
if (create)
|
|
1233
1233
|
await mkdir(lexical, { recursive: true, mode: 0o700 });
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1234
|
+
// Resolve once into the physical path used for every containment and
|
|
1235
|
+
// identity comparison below. System-owned prefix symlinks are normal on
|
|
1236
|
+
// macOS (e.g. /var -> /private/var); symlinks at or below the container
|
|
1237
|
+
// stay rejected by the managed-path lstat checks.
|
|
1238
|
+
return canonicalDirectory(lexical, "Worktree container");
|
|
1238
1239
|
}
|
|
1239
1240
|
async function canonicalDirectory(path, label) {
|
|
1240
1241
|
const value = requireText(path, label);
|
|
@@ -15,7 +15,7 @@ import { taskRoleRuntimeIdentity } from "../runtime/managedCaller.js";
|
|
|
15
15
|
import { managedTaskRoot } from "../storage/homeLayout.js";
|
|
16
16
|
import { StorageConflictError } from "../storage/taskStore.js";
|
|
17
17
|
import { validateDraftTaskForActivation } from "../task/draftPlan.js";
|
|
18
|
-
import { activateTask, bindTaskProjectCommits, bindTaskWorkspaceIdentity, synchronizeTaskProjectCommits
|
|
18
|
+
import { activateTask, bindTaskProjectCommits, bindTaskWorkspaceIdentity, synchronizeTaskProjectCommits } from "../task/task.js";
|
|
19
19
|
import { admitStoredTaskActivation, adoptTaskActivationResources, recordAdoptedTaskActivation, recordFailedTaskActivation } from "../task/taskActivationService.js";
|
|
20
20
|
import { taskLocalActor } from "../task/taskAuthority.js";
|
|
21
21
|
import { createCandidateGitSnapshot, createDirectTaskMainSnapshot, recordWorkItemWorkspaceDisposition, workItemExecutionGroupById } from "../workItem/workItem.js";
|
|
@@ -140,11 +140,9 @@ export class FileTaskWorkspacePreparer {
|
|
|
140
140
|
const actor = task.activationRequest?.operation.actorId === `task:${task.id}/role:leader`
|
|
141
141
|
? "leader" : caller;
|
|
142
142
|
if (task.status === "active") {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
throw new Error(`Active Task workspace adoption is invalid: ${task.id}.`);
|
|
147
|
-
}
|
|
143
|
+
const workspace = this.store.getTaskWorkspace(task.id);
|
|
144
|
+
if (!isTaskOwnedWorkspace(workspace, task.id, task.cwd, task.projectBindings.map(({ projectId, directory }) => ({ projectId, directory })))) {
|
|
145
|
+
throw new Error(`Active Task workspace adoption is invalid: ${task.id}.`);
|
|
148
146
|
}
|
|
149
147
|
return {
|
|
150
148
|
task,
|
|
@@ -210,9 +208,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
210
208
|
// Acquire before resource adoption: a timed-out/cancelled waiter must
|
|
211
209
|
// not leave an adopted environment to be repeated on the next attempt.
|
|
212
210
|
const adopted = await this.#adoptActivationEnvironment(current);
|
|
213
|
-
return adopted
|
|
214
|
-
? this.#activateWorkspaceFreeTask(current.id, adopted, actor)
|
|
215
|
-
: await this.#prepareTaskWorkspaceLocked(current.id, true, adopted, actor);
|
|
211
|
+
return await this.#prepareTaskWorkspaceLocked(current.id, true, adopted, actor);
|
|
216
212
|
}
|
|
217
213
|
finally {
|
|
218
214
|
release();
|
|
@@ -222,10 +218,6 @@ export class FileTaskWorkspacePreparer {
|
|
|
222
218
|
* Prepares and adopts the resource configuration an explicit activation
|
|
223
219
|
* request named, before any status change.
|
|
224
220
|
*
|
|
225
|
-
* A request whose plan is empty and whose Task binds no Project owns nothing
|
|
226
|
-
* physical, and is reported as workspace-free
|
|
227
|
-
* so activation creates no directory at all.
|
|
228
|
-
*
|
|
229
221
|
* The exact request that was adopted travels with the result, so the later
|
|
230
222
|
* status transaction can prove it is completing that request rather than
|
|
231
223
|
* whichever one occupies the slot by then.
|
|
@@ -241,8 +233,6 @@ export class FileTaskWorkspacePreparer {
|
|
|
241
233
|
// rechecks. A failure here leaves the Task a continuable Draft.
|
|
242
234
|
const adopted = adoptTaskActivationResources(this.store, task.id, this.now);
|
|
243
235
|
return {
|
|
244
|
-
workspaceFree: admission.plan.kind === "empty"
|
|
245
|
-
&& task.projectBindings.length === 0,
|
|
246
236
|
// The request whose resources were actually adopted, re-checked at the
|
|
247
237
|
// status transaction: a cancel or replacement landing during workspace
|
|
248
238
|
// preparation must fail rather than silently swap requests.
|
|
@@ -254,47 +244,6 @@ export class FileTaskWorkspacePreparer {
|
|
|
254
244
|
: { preparationId: adopted.preparation.id })
|
|
255
245
|
};
|
|
256
246
|
}
|
|
257
|
-
/**
|
|
258
|
-
* Moves a Task that owns nothing physical from Draft to active.
|
|
259
|
-
*
|
|
260
|
-
* An empty environment plan with no bound Project is a legal Task shape, so
|
|
261
|
-
* no workspace view, worktree or cwd is created to satisfy the framework's
|
|
262
|
-
* own model. The status change and the adopted request commit together.
|
|
263
|
-
*/
|
|
264
|
-
#activateWorkspaceFreeTask(taskId, environment, actor) {
|
|
265
|
-
const task = this.store.transaction((tx) => {
|
|
266
|
-
const latest = requireTask(tx, taskId);
|
|
267
|
-
if (latest.status !== "draft") {
|
|
268
|
-
throw new Error(`Task changed while activating it: ${taskId}/${latest.status}.`);
|
|
269
|
-
}
|
|
270
|
-
if (latest.projectBindings.length !== 0
|
|
271
|
-
|| latest.cwd !== undefined
|
|
272
|
-
|| latest.workspaceIdentity !== undefined
|
|
273
|
-
|| tx.getTaskWorkspace(taskId) !== null) {
|
|
274
|
-
throw new Error(`Task acquired workspace state while activating it: ${taskId}.`);
|
|
275
|
-
}
|
|
276
|
-
validateDraftTaskForActivation(tx, latest);
|
|
277
|
-
const timestamp = this.now();
|
|
278
|
-
// Bind before the handover below: the desired environment must be the
|
|
279
|
-
// adopted one before the obligation that ends the old physical Host
|
|
280
|
-
// exists.
|
|
281
|
-
if (environment.preparationId !== undefined) {
|
|
282
|
-
this.#bindAdoptedActivationEnvironment(tx, taskId, environment.preparationId);
|
|
283
|
-
}
|
|
284
|
-
// The physical launch is unchanged, but "unchanged" is not "compatible":
|
|
285
|
-
// the Draft's planning Session runs in the Role's inherited workspace,
|
|
286
|
-
// which is exactly the shared cwd an active Task may not execute in. Hand
|
|
287
|
-
// the conversation over instead of asserting continuity, so the next
|
|
288
|
-
// launch is re-planned under the execution rules and must name a real
|
|
289
|
-
// environment. Its plan and grants are re-proven there, not assumed here.
|
|
290
|
-
handOverPlanningSession(tx, taskId, timestamp);
|
|
291
|
-
const activated = this.#recordAdoptedActivation(tx, activateTask(latest, timestamp), environment, timestamp);
|
|
292
|
-
tx.saveTask(activated);
|
|
293
|
-
recordTaskActivation(tx, latest, activated, timestamp, actor);
|
|
294
|
-
return activated;
|
|
295
|
-
});
|
|
296
|
-
return { task, taskId, status: "ready", changed: true };
|
|
297
|
-
}
|
|
298
247
|
/**
|
|
299
248
|
* Marks the Task's activation request adopted, inside the caller's activation
|
|
300
249
|
* transaction. Returns the Task unchanged when no explicit request exists.
|
|
@@ -406,7 +355,7 @@ export class FileTaskWorkspacePreparer {
|
|
|
406
355
|
}
|
|
407
356
|
async #prepareTaskWorkspaceLocked(taskId, activate,
|
|
408
357
|
/** Environment and request identity the activation adopted, when one exists. */
|
|
409
|
-
activationEnvironment = {
|
|
358
|
+
activationEnvironment = {}, actor = "user") {
|
|
410
359
|
const task = requireTask(this.store, taskId);
|
|
411
360
|
if (task.executionGate.state !== "enabled") {
|
|
412
361
|
throw new Error(`Task execution is not enabled: ${task.id}.`);
|
|
@@ -695,6 +644,9 @@ export class FileTaskWorkspacePreparer {
|
|
|
695
644
|
throw new StorageConflictError(`Task workspace changed while preparing its workspace: ${task.id}.`);
|
|
696
645
|
}
|
|
697
646
|
const timestamp = this.now();
|
|
647
|
+
const mainWorkspaceChanged = current !== null
|
|
648
|
+
&& (current.root !== workspace.root
|
|
649
|
+
|| !isDeepStrictEqual(current.entries.map(({ projectId, directory, access, path, branch }) => ({ projectId, directory, access, path, branch })), workspace.entries.map(({ projectId, directory, access, path, branch }) => ({ projectId, directory, access, path, branch }))));
|
|
698
650
|
let persistedTask = latest;
|
|
699
651
|
if (activate) {
|
|
700
652
|
// Activation moves the Leader out of the Draft planning cwd, so its
|
|
@@ -784,6 +736,12 @@ export class FileTaskWorkspacePreparer {
|
|
|
784
736
|
}
|
|
785
737
|
tx.saveRole(task.id, updateRole(role, { workspace: target }, timestamp));
|
|
786
738
|
}
|
|
739
|
+
else if (target === root && mainWorkspaceChanged) {
|
|
740
|
+
// A newly bound Project changes the native directory grants even
|
|
741
|
+
// though the Task cwd stays at the same root. Do not resume a
|
|
742
|
+
// Session frozen before that Project was present.
|
|
743
|
+
retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
|
|
744
|
+
}
|
|
787
745
|
}
|
|
788
746
|
if (activate)
|
|
789
747
|
recordTaskActivation(tx, latest, persistedTask, timestamp, actor);
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* consumes another Issue's completion flag. Any source that cannot be read
|
|
6
6
|
* fails closed: the resource is treated as referenced and retained.
|
|
7
7
|
*/
|
|
8
|
-
import { execFile } from "node:child_process";
|
|
8
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
10
|
import { existsSync, readFileSync, readdirSync, readlinkSync } from "node:fs";
|
|
11
11
|
import { join, resolve } from "node:path";
|
|
12
12
|
import { promisify } from "node:util";
|
|
13
|
+
import { processOwnerIsLive } from "../core/fileLockOwner.js";
|
|
13
14
|
import { readRuntimeIdentity, releasesDirectory, resolveActiveRelease } from "../release/runtimeRelease.js";
|
|
14
15
|
import { isControllerSocketPathForHome } from "../core/controllerEndpoint.js";
|
|
15
16
|
import { parseControllerDiscovery } from "../core/protocol.js";
|
|
@@ -172,8 +173,10 @@ function readRuntimeIdentityClaims(home) {
|
|
|
172
173
|
if (!isProcessAlive(identity.pid)) {
|
|
173
174
|
return Object.freeze({ claims: Object.freeze([]), diagnostics: Object.freeze([]) });
|
|
174
175
|
}
|
|
175
|
-
const
|
|
176
|
-
|
|
176
|
+
const sameGeneration = process.platform === "linux"
|
|
177
|
+
? readLinuxProcessStartIdentity(identity.pid) === identity.processStartIdentity
|
|
178
|
+
: processOwnerIsLive(identity.pid, identity.processStartIdentity);
|
|
179
|
+
if (!sameGeneration) {
|
|
177
180
|
return Object.freeze({
|
|
178
181
|
claims: Object.freeze([]),
|
|
179
182
|
diagnostics: Object.freeze([{
|
|
@@ -242,8 +245,10 @@ export function readSessionOwnerClaims(records) {
|
|
|
242
245
|
// The normal reconciliation owner releases dead custody records.
|
|
243
246
|
continue;
|
|
244
247
|
}
|
|
245
|
-
const
|
|
246
|
-
|
|
248
|
+
const sameGeneration = process.platform === "linux"
|
|
249
|
+
? readLinuxProcessStartIdentity(pid) === startIdentity
|
|
250
|
+
: processOwnerIsLive(pid, startIdentity);
|
|
251
|
+
if (!sameGeneration) {
|
|
247
252
|
diagnostics.push({
|
|
248
253
|
source: "controller",
|
|
249
254
|
severity: "error",
|
|
@@ -270,6 +275,8 @@ export function readSessionOwnerClaims(records) {
|
|
|
270
275
|
* scan fails closed: every candidate path receives a protective ref.
|
|
271
276
|
*/
|
|
272
277
|
export function scanProcessPathRefs(paths) {
|
|
278
|
+
if (process.platform === "darwin")
|
|
279
|
+
return scanDarwinProcessPathRefs(paths);
|
|
273
280
|
const refs = new Map();
|
|
274
281
|
const diagnostics = [];
|
|
275
282
|
const add = (path, token) => {
|
|
@@ -335,6 +342,54 @@ export function scanProcessPathRefs(paths) {
|
|
|
335
342
|
}
|
|
336
343
|
return freezeResult(refs, diagnostics);
|
|
337
344
|
}
|
|
345
|
+
/** lsof supplies the same-user cwd and open-file observations on macOS. */
|
|
346
|
+
function scanDarwinProcessPathRefs(paths) {
|
|
347
|
+
const refs = new Map();
|
|
348
|
+
try {
|
|
349
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
350
|
+
const output = execFileSync("/usr/sbin/lsof", [
|
|
351
|
+
"-nP", "-w", "-F0pfn", "-u", String(uid)
|
|
352
|
+
], { encoding: "utf8", timeout: 10_000, maxBuffer: 16 * 1024 * 1024 });
|
|
353
|
+
if (output.includes("\ufffd"))
|
|
354
|
+
throw new Error("lsof returned an undecodable path.");
|
|
355
|
+
let pid = "";
|
|
356
|
+
let descriptor = "";
|
|
357
|
+
let sawProcess = false;
|
|
358
|
+
for (const raw of output.split("\0")) {
|
|
359
|
+
const field = raw.replace(/^\n+/u, "");
|
|
360
|
+
if (field.startsWith("p") && /^[1-9][0-9]*$/u.test(field.slice(1))) {
|
|
361
|
+
pid = field.slice(1);
|
|
362
|
+
descriptor = "";
|
|
363
|
+
sawProcess = true;
|
|
364
|
+
}
|
|
365
|
+
else if (field.startsWith("f")) {
|
|
366
|
+
descriptor = field.slice(1);
|
|
367
|
+
}
|
|
368
|
+
else if (field.startsWith("n") && pid !== "" && field[1] === "/") {
|
|
369
|
+
const observed = field.slice(1);
|
|
370
|
+
for (const path of paths) {
|
|
371
|
+
if (!isPathWithin(observed, path))
|
|
372
|
+
continue;
|
|
373
|
+
const tokens = refs.get(path) ?? new Set();
|
|
374
|
+
tokens.add(`lsof:${descriptor}:${pid}`);
|
|
375
|
+
refs.set(path, tokens);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (!sawProcess)
|
|
380
|
+
throw new Error("lsof did not return a process inventory.");
|
|
381
|
+
return freezeResult(refs, []);
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
385
|
+
const diagnostics = [{
|
|
386
|
+
source: "proc", severity: "error", message: `macOS process path scan failed: ${detail}`
|
|
387
|
+
}];
|
|
388
|
+
for (const path of paths)
|
|
389
|
+
refs.set(path, new Set(["lsof:unverified"]));
|
|
390
|
+
return freezeResult(refs, diagnostics);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
338
393
|
function freezeResult(refs, diagnostics) {
|
|
339
394
|
const frozen = new Map();
|
|
340
395
|
for (const [path, tokens] of refs)
|
|
@@ -435,8 +490,10 @@ export function readControllerDiscovery(home) {
|
|
|
435
490
|
}
|
|
436
491
|
// PID is alive. When a start identity is recorded, verify it to guard
|
|
437
492
|
// against PID reuse.
|
|
438
|
-
const
|
|
439
|
-
|
|
493
|
+
const sameGeneration = process.platform === "linux"
|
|
494
|
+
? readLinuxProcessStartIdentity(record.pid) === record.processStartIdentity
|
|
495
|
+
: processOwnerIsLive(record.pid, record.processStartIdentity);
|
|
496
|
+
if (!sameGeneration) {
|
|
440
497
|
return Object.freeze({
|
|
441
498
|
protects: false,
|
|
442
499
|
token: "",
|