toga-ai 1.0.708 → 1.0.710
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/knowledge/2.0/apps/worker2/INDEX.md +1 -1
- package/knowledge/2.0/apps/worker2/architecture.md +17 -5
- package/knowledge/2.0/apps/worker2/features/clickup-project-routing.md +18 -1
- package/knowledge/2.0/apps/worker2/features/creating-worker-actions.md +127 -1
- package/knowledge/2.0/apps/worker2/features/netsuite-opportunity-client-labels.md +71 -1
- package/knowledge/2.0/apps/worker2/features/netsuite-opportunity-sync.md +119 -2
- package/knowledge/standalone/apps/claude/workflows/mcp-tool-usage.md +8 -3
- package/package.json +1 -1
- package/rules/common/communication.md +23 -0
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
| [ClickUp Task Description Fluffer (Talos KB Investigation)](features/clickup-task-fluffer-talos.md) | `_Worker_Clickup_Fluffer` "fluffs" a ClickUp task by sending its name + existing description to the **Talos `dev-core` KB-investigation agent** and writing the | worker2/Worker/Clickup/Fluffer.php, worker2/Worker/Clickup.php, worker2/Config/production.ini, worker2/Config/beta.ini, worker2/Config/dev-rohan-mac.ini |
|
|
18
18
|
| [ClickUp Work Type Automation (Committed / Conditional / Stretch)](features/clickup-work-type-automation.md) | The ClickUp webhook handler (`_Worker_Clickup`) automatically maintains each task's **Work Type** custom field — `Committed`, `Conditional`, or `Stretch` — base | worker2/Worker/Clickup.php, worker2/Tests/Worker/ClickupWorkTypeTest.php |
|
|
19
19
|
| [Compass VIP Support Importer (worker2)](features/compass-vip-support-importer.md) | A worker2 action that ingests Compass's quarterly VIP spreadsheet and assigns each VIP user's support technician by setting `Users.c_supportedByUserId` in `Clie | worker2/Worker/Client/Compass/VipSupport.php |
|
|
20
|
-
| [Creating Worker Actions](features/creating-worker-actions.md) | How to add a new callable Worker action — a PHP class whose `public static` methods are invoked as background jobs (via webhook, cron, or `_Worker::runTask()`). | worker2/Worker/, worker2/Controller/Index.php, _underscore/Worker.php |
|
|
20
|
+
| [Creating Worker Actions](features/creating-worker-actions.md) | How to add a new callable Worker action — a PHP class whose `public static` methods are invoked as background jobs (via webhook, cron, or `_Worker::runTask()`). | worker2/Worker/, worker2/Controller/Index.php, worker2/LambdaFunctions/JobScheduler/lambda_function.py, _underscore/Worker.php |
|
|
21
21
|
| [Cross-account AWS access for worker2 crons (_Component_Aws_Workloads)](features/cross-account-aws-access.md) | `_Component_Aws_Workloads` is the **standard, and only sanctioned, way any new worker2 cron obtains AWS access** — for any account, any region, any AWS SDK clie | worker2/Component/Aws/Workloads/Workloads.php, worker2/Config/production.ini, worker2/Worker/Infrastructure/CloudWatch.php, worker2/Worker/Monitor/Fleet.php, library/app/worker.php, library/app/cloud.php |
|
|
22
22
|
| [Elastic Beanstalk health monitor → OneUptime (ElasticBeanstalkHealth)](features/elastic-beanstalk-health-monitor.md) | `_Worker_Infrastructure_CloudWatch::ElasticBeanstalkHealth()` is a worker2 cron that reads each Elastic Beanstalk (EB) environment's health and reports it to a | worker2/Worker/Infrastructure/CloudWatch.php |
|
|
23
23
|
| [Elite Freshservice Sync (worker2)](features/elite-freshservice-sync.md) | `_Worker_Elite` processes Freshservice webhook events and syncs them into TOGA 2. | worker2/Worker/Elite.php, worker2/Config/dev-kmaramreddy-laptop.ini |
|
|
@@ -6,8 +6,8 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: [jcardinal]
|
|
9
|
+
updated: 2026-08-31
|
|
10
|
+
owners: [jcardinal, ajean]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Controller/Index.php
|
|
13
13
|
- worker2/Worker/
|
|
@@ -62,7 +62,9 @@ HTTP-triggered job endpoints must enforce auth.
|
|
|
62
62
|
`Core.WorkerJobs`, so never treat a queue message as the record of a job. Every worker/webhook
|
|
63
63
|
endpoint must return **HTTP 200 on every path, including failure** (failures are recorded in
|
|
64
64
|
`WorkerJobs`): there is no DLQ and the visibility timeout is 3600s, so a single 500 becomes a
|
|
65
|
-
poison-message storm
|
|
65
|
+
poison-message storm — but 3600s is **delivery**, not an execution budget: the watchdog fails any
|
|
66
|
+
job past `maxExecutionTime`, and that override only applies to CronScheduler-fired jobs, so every
|
|
67
|
+
webhook / `runTask` / curl action is hard-capped at **300s** and anything longer must self-chunk. Workers are `abstract class _Worker_<Name>` with `public static` entry
|
|
66
68
|
methods and are invoked **only** through `_Worker::runTask(action, parameters)` — never call a
|
|
67
69
|
worker method directly. This repo is PHP and Python only: never add a `.sql` file here, as all
|
|
68
70
|
schema changes and cron seeds belong to `dbchanges2`. Both environments run **PHP 8.5**, and the
|
|
@@ -127,8 +129,18 @@ fields on other tables: the `Email` table (`Worker/Infrastructure/Email/Send.php
|
|
|
127
129
|
2. **PayloadRecovery** — every minute, recovers S3 fallback payloads into `WorkerJobs`.
|
|
128
130
|
3. **JobScheduler** — every minute. **Part A:** picks up `dtQueued IS NULL AND isSuccess
|
|
129
131
|
IS NULL` (≤100), sets `dtQueued=NOW()`, sends to queue. **Part B (watchdog):** marks
|
|
130
|
-
jobs `isSuccess=0` when `NOW() > dtStarted + timeout`
|
|
131
|
-
|
|
132
|
+
jobs `isSuccess=0` when `NOW() > dtStarted + timeout` with `dtCompleted IS NULL`.
|
|
133
|
+
⚠ **The `CronJobs.maxExecutionTime` override is reachable ONLY for scheduler-fired jobs.**
|
|
134
|
+
Part B resolves the timeout through `LEFT JOIN CronJobs cj ON wj.cronJobId = cj.id`, and
|
|
135
|
+
`cronJobId` is NULL on every job the CronScheduler did not fire — webhooks, `_Worker::runTask`
|
|
136
|
+
enqueues, direct `curl`. Those are **all** capped at env `MAX_EXECUTION_TIME_WORKER_ACTION`
|
|
137
|
+
(default 300s) with **no per-job override**; inserting a `CronJobs` row with a larger
|
|
138
|
+
`maxExecutionTime` changes nothing unless the scheduler is what fires the job. And the watchdog
|
|
139
|
+
only writes to the row — **it does not stop the PHP process**, so an over-budget action keeps
|
|
140
|
+
running past a false failure row and typically loses its result `UPDATE` to MySQL 2006 "server
|
|
141
|
+
has gone away" (prod job 1075223, 2026-08-28: failed at 326s, worked to 818s, report lost).
|
|
142
|
+
Long work must self-chunk — see
|
|
143
|
+
[creating-worker-actions](./features/creating-worker-actions.md#self-chunking-the-pattern-for-work-that-cannot-finish-in-one-job).
|
|
132
144
|
4. **CronScheduler** — every minute, evaluates active `CronJobs.schedule` against the
|
|
133
145
|
current Central-tz minute (`croniter`); on match INSERTs a `CRON` job and queues it.
|
|
134
146
|
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-08-31
|
|
10
10
|
owners: [jcardinal, ajean]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Clickup/Project.php
|
|
@@ -14,6 +14,7 @@ files:
|
|
|
14
14
|
related:
|
|
15
15
|
- ../architecture.md
|
|
16
16
|
- ./creating-worker-actions.md
|
|
17
|
+
- ./netsuite-opportunity-sync.md
|
|
17
18
|
- ./clickup-connectivity-watchdog.md
|
|
18
19
|
- ./clickup-subtask-activity.md
|
|
19
20
|
---
|
|
@@ -148,6 +149,17 @@ None — uniform across all clients (shared internal automation for Agilant's Cl
|
|
|
148
149
|
- **POST failures are fatal, DELETE failures are tolerated** — a failed ADD leaves the task
|
|
149
150
|
out of its correct list (surfaced as job failure); a failed DELETE (most likely TASK_035,
|
|
150
151
|
which shouldn't occur on secondary lists) is logged with `[ClickupProject]` and skipped.
|
|
152
|
+
- **The unowned side of the hybrid split bites triage: home-list moves happen that no code explains.**
|
|
153
|
+
Because the worker provably cannot move a home list (TASK_035), any observed home-list change is
|
|
154
|
+
either a ClickUp native **Automation** or a **human** — and neither is visible in our logs, nor
|
|
155
|
+
reliably readable through the API. Live case 2026-08-31: opportunity tasks created by the NetSuite
|
|
156
|
+
sync into `901111987449` ("Presales Qualification") were found homed in `901111987451`
|
|
157
|
+
("Quote/Proposal/SOW/MSA") in the same folder `90117060687` ("Opportunities"), space `90113928591`
|
|
158
|
+
— and the movement was **not cleanly stage-driven** (two tasks created the same day at the same
|
|
159
|
+
Stage 25%, only one moved), so the mover is **unidentified**. Practical rule: **never assess a
|
|
160
|
+
pipeline's health by the contents of one list**; assess by task id or by the whole folder. Detail
|
|
161
|
+
and evidence in
|
|
162
|
+
[NetSuite ↔ ClickUp Opportunity Sync](./netsuite-opportunity-sync.md#triage-is-the-opportunity-sync-working-workerjobsoutput-verdicts).
|
|
151
163
|
- **Webhook subscriptions** must cover each space, all → `webhook.togahub.com/clickup`. Routing:
|
|
152
164
|
legacy sprint space `90020178491`, Project Hub `90113939341`, Lifecycle Services
|
|
153
165
|
`90114156087`; Opportunity Hub via `RegisterOpportunityWebhook()`. The hub guard additionally
|
|
@@ -164,6 +176,11 @@ Read-only / setup actions, invoked via `curl -X POST https://worker.togahub.com/
|
|
|
164
176
|
`RESULT: PASS`), `RegisterOpportunityWebhook` and `RegisterHubGuardWebhooks` (one-time setup).
|
|
165
177
|
|
|
166
178
|
## Change history
|
|
179
|
+
- 2026-08-31 — Recorded the triage consequence of the hybrid split (read-only investigation, no code
|
|
180
|
+
changed): worker-created opportunity tasks were observed **homed in `901111987451`
|
|
181
|
+
"Quote/Proposal/SOW/MSA"** rather than the `901111987449` list they were POSTed to, with the mover
|
|
182
|
+
**unidentified** (not cleanly stage-driven — ClickUp Automation or a human). Rule added: judge a
|
|
183
|
+
pipeline's health by task id or folder, never by one list. (ajean)
|
|
167
184
|
- 2026-07-14 — Correction: `TASK_TYPE_EPIC_ID` / `TASK_TYPE_OPPORTUNITY_ID` are still `null` and the Task-Types ClickApp is not reliably enabled on the hub spaces, so `enforceHubPlacement`'s custom-Task-Type keying is inert; structural (space + top-level) identification is what actually runs. Cross-linked the new subtask-activity feature (same hub spaces/pipeline). (ajean)
|
|
168
185
|
- 2026-06-23 — Added the hub-placement guard (`enforceHubPlacement` on `taskCreated`/`taskMoved`): re-anchors misplaced Epics/Opportunities to their hub by custom Task Type + home space, backfills Business Unit/Project Category, and notifies. Added `DiscoverTaskTypes`, `RegisterHubGuardWebhooks`, `TestHubGuardMappings`. Corrected the earlier "Task Types not enabled" note. (ajean)
|
|
169
186
|
- 2026-06-09 — Documented ClickUp secondary multi-list routing (hybrid native-automation + worker design, three space handlers, self-trigger guard). (jcardinal)
|
|
@@ -6,14 +6,16 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-31
|
|
10
10
|
owners: [jcardinal, dfranks, mhammontree, tcox, bala, ajean]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/
|
|
13
13
|
- worker2/Controller/Index.php
|
|
14
|
+
- worker2/LambdaFunctions/JobScheduler/lambda_function.py
|
|
14
15
|
- _underscore/Worker.php
|
|
15
16
|
related:
|
|
16
17
|
- ../architecture.md
|
|
18
|
+
- ./netsuite-opportunity-client-labels.md
|
|
17
19
|
- ../../api2/features/api-payload-interceptors.md
|
|
18
20
|
---
|
|
19
21
|
|
|
@@ -203,6 +205,103 @@ So **do not design "throw so it retries" — it won't.** To actually retry / sel
|
|
|
203
205
|
Rule of thumb: `throw` to surface a genuine failure; `runTask`/in-process loop when the work should
|
|
204
206
|
be reattempted.
|
|
205
207
|
|
|
208
|
+
## ⚠ The real time budget is 300s — and for a hand-triggered action it CANNOT be raised
|
|
209
|
+
|
|
210
|
+
The 3600s SQS visibility timeout is **not** your budget, and the worker2 docs previously implied it
|
|
211
|
+
was. The **JobScheduler watchdog** (`LambdaFunctions/JobScheduler/lambda_function.py`, Part B) fails
|
|
212
|
+
any job whose `dtStarted` is older than its timeout while `dtCompleted IS NULL` — and it resolves
|
|
213
|
+
that timeout from `CronJobs.maxExecutionTime` **only through
|
|
214
|
+
`LEFT JOIN CronJobs cj ON wj.cronJobId = cj.id`**.
|
|
215
|
+
|
|
216
|
+
`cronJobId` is **NULL for anything the CronScheduler did not fire** — every webhook job, every
|
|
217
|
+
`_Worker::runTask()` enqueue, every `curl` to the worker. All of those fall back to env
|
|
218
|
+
`MAX_EXECUTION_TIME_WORKER_ACTION`, **default 300s**, and there is **no per-job override**: adding a
|
|
219
|
+
`Core.CronJobs` row with a large `maxExecutionTime` buys you nothing unless the **scheduler** is the
|
|
220
|
+
thing that fires the job (only then is `cronJobId` populated). "I gave the job an hour" is not
|
|
221
|
+
something you can do for an ad-hoc action.
|
|
222
|
+
|
|
223
|
+
Worse, the watchdog only writes to the row — **the PHP process keeps running**. Measured in
|
|
224
|
+
production (2026-08-28, job `1075223`, a full `OpportunityStakeholderBackfill` dry run):
|
|
225
|
+
|
|
226
|
+
| t | what happened |
|
|
227
|
+
|---|---|
|
|
228
|
+
| 326s | watchdog stamps `isSuccess=0`, `output = 'watchdog: exceeded maxExecutionTime without completing'` |
|
|
229
|
+
| 326–818s | the action carries on working, unaware it has been declared dead |
|
|
230
|
+
| 818s | its own result `UPDATE` dies on MySQL **2006 "server has gone away"** (`Logs.Issue` ref 6E) — the entire report is lost; HTTP returned 500 carrying only `Error reference: 6E-190` |
|
|
231
|
+
|
|
232
|
+
So an over-budget action loses its output **twice**: once to a false failure row, once to a dropped
|
|
233
|
+
connection. Design for 300s.
|
|
234
|
+
|
|
235
|
+
### Self-chunking: the pattern for work that cannot finish in one job
|
|
236
|
+
|
|
237
|
+
An action that may exceed the budget works for a **sub-budget slice**, then **re-enqueues itself**
|
|
238
|
+
via `_Worker::runTask()` carrying its cursor and running totals in a `resume` parameter. One
|
|
239
|
+
`WorkerJobs` row per chunk, each labelled so a mid-run row is never misread as the final answer
|
|
240
|
+
(`'chunk 4, run CONTINUES'` vs `'run COMPLETE'`). Proven by
|
|
241
|
+
`Worker/Netsuite/OpportunityStakeholderBackfill.php` (`CHUNK_TIME_BUDGET_SECONDS = 180`; live passes
|
|
242
|
+
of 5–11 chunks, 15–32 minutes, 0 failures) — see
|
|
243
|
+
[netsuite-opportunity-client-labels](./netsuite-opportunity-client-labels.md).
|
|
244
|
+
|
|
245
|
+
```php
|
|
246
|
+
const CHUNK_TIME_BUDGET_SECONDS = 180;
|
|
247
|
+
|
|
248
|
+
public static function Backfill(array|object $resume = []): string {
|
|
249
|
+
$startedAt = microtime(true); // TOP OF THE JOB, not top of the loop
|
|
250
|
+
$resume = (array) json_decode(json_encode($resume), true);
|
|
251
|
+
$cursor = (int) ($resume['cursor'] ?? 0);
|
|
252
|
+
$totals = $resume['totals'] ?? [];
|
|
253
|
+
|
|
254
|
+
$rows = self::fetchBatch($cursor); // keyset: WHERE o.id > $cursor ORDER BY o.id ASC
|
|
255
|
+
_Database::transactionCommit(self::SOURCE_DB); // commit the read BEFORE any remote call
|
|
256
|
+
|
|
257
|
+
if (!$rows) return self::report($totals, complete: true);
|
|
258
|
+
|
|
259
|
+
$processed = 0;
|
|
260
|
+
foreach ($rows as $row) {
|
|
261
|
+
// forward-progress invariant: always do at least one before honouring the deadline
|
|
262
|
+
if ($processed > 0 && (microtime(true) - $startedAt) >= self::CHUNK_TIME_BUDGET_SECONDS) break;
|
|
263
|
+
self::processOne($row, $totals);
|
|
264
|
+
$cursor = (int) $row->id;
|
|
265
|
+
$processed++;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
_Worker::runTask(self::ACTION, ['resume' => ['cursor' => $cursor, 'totals' => $totals]]);
|
|
269
|
+
return self::report($totals, complete: false);
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Five design points, each of which was a real bug or near-miss:
|
|
274
|
+
|
|
275
|
+
- **Measure the deadline from the top of the JOB, not the top of the loop.** Startup work (option-list
|
|
276
|
+
fetch, alias load) then sits *inside* the budget instead of being added on top of it.
|
|
277
|
+
- **Use a KEYSET cursor (`o.id > $cursor` + an explicit `ORDER BY o.id ASC`), never `OFFSET`.** Rows
|
|
278
|
+
enter the working set continuously, so an `OFFSET` silently skips or repeats rows when a new one
|
|
279
|
+
lands mid-run. A query with **no `ORDER BY`** is harmless when read once and *meaningless* across
|
|
280
|
+
jobs — chunking a paginated query is exactly when a missing `ORDER BY` starts corrupting results.
|
|
281
|
+
- **Enforce a forward-progress invariant: every chunk must advance the cursor by ≥1 unit of work.**
|
|
282
|
+
Without it, a chunk whose budget is already blown at loop start processes nothing, leaves the
|
|
283
|
+
cursor unchanged, and re-enqueues an identical job — an **unbounded chain of production jobs**.
|
|
284
|
+
- **Bound the carried detail by entry count AND per-entry length.** A count cap alone does not bound
|
|
285
|
+
bytes: failure entries embed API exception messages. Truncate by walking the tail back until
|
|
286
|
+
`json_encode()` succeeds — a severed multi-byte character makes `json_encode()` return `false` for
|
|
287
|
+
the *whole* message, and `runTask` would then hand SQS an empty body.
|
|
288
|
+
- **Commit the source-DB read as soon as the rows are in hand** — before any remote call and before
|
|
289
|
+
the early returns. Every run ends on an empty-batch hop, which otherwise leaves a lazy transaction
|
|
290
|
+
open (see [_underscore architecture](../../_underscore/architecture.md)).
|
|
291
|
+
|
|
292
|
+
### Do NOT retry the chunk hand-off — a failed hand-off must break the chain
|
|
293
|
+
|
|
294
|
+
`_Worker::runTask()` **mints the idempotency uuid itself, per call** (`_underscore/Worker.php`), and
|
|
295
|
+
that uuid is the only key the dispatcher's dedupe guard matches on. So a send that reaches SQS and
|
|
296
|
+
then throws on the way back would be retried under a **different** uuid, the guard would not match,
|
|
297
|
+
and **both** messages run: the chain **forks** into two chains from one cursor, each spawning its own
|
|
298
|
+
successors — double downstream load and two disagreeing `COMPLETE` reports.
|
|
299
|
+
|
|
300
|
+
A broken chain is strictly safer: it is loud, it reports the restart cursor, and it costs one manual
|
|
301
|
+
restart. **Single attempt only, deliberately.** Making a retry safe requires threading a stable
|
|
302
|
+
per-cursor uuid through `runTask` *and* the `Controller/Index.php` dedupe — shared framework core,
|
|
303
|
+
cross-repo, and out of scope for a caller. Do not "fix" this back to a retry loop.
|
|
304
|
+
|
|
206
305
|
## `debug_mode` changes how `runTask` dispatches — and the debug path can't do read-after-write
|
|
207
306
|
|
|
208
307
|
`_Worker::runTask` has two completely different behaviours:
|
|
@@ -280,6 +379,18 @@ Rules: rename **both sides in one PR and one deploy**; grep every repo for the o
|
|
|
280
379
|
to PHP arrays fine — only JSON **objects** become `stdClass`.) This bit
|
|
281
380
|
`_Worker_Infrastructure_CloudWatch::ElasticBeanstalkHealth`'s `environments` region-map in
|
|
282
381
|
production — see [cross-account-aws-access.md](./cross-account-aws-access.md).
|
|
382
|
+
**This applies to BOTH dispatch paths, so no invocation style escapes it:** the stored-row path
|
|
383
|
+
(`Controller/Index.php` ~L318) and the direct-payload/`runTask` path (~L455) each do a shallow
|
|
384
|
+
`(array) json_decode($job->parameters)`. So a structured parameter breaks on the **first**
|
|
385
|
+
dispatch whether it came from a cron row, a `curl`, or a `runTask` re-enqueue.
|
|
386
|
+
It is also **asymmetric on the return trip**: an associative array you send comes back
|
|
387
|
+
`stdClass`, while a PHP **list** comes back a plain array — so a shallow `(array)` cast is not
|
|
388
|
+
enough for anything nested. Verified on PHP 8.5: `null`, `array` and `object` all `TypeError`
|
|
389
|
+
against a userland `string`/`array` parameter, while **ints coerce silently** (so a wrong-typed
|
|
390
|
+
numeric fails *later*, not at dispatch). **Fix pattern:** declare `array|object` and normalize
|
|
391
|
+
with a full round trip — `(array) json_decode(json_encode($param), true)` — which flattens every
|
|
392
|
+
level at once. Any action taking a structured parameter (a `resume` cursor, a region map, an
|
|
393
|
+
options bag) needs this.
|
|
283
394
|
- Webhook headers are **lowercased** by API Gateway (`X-GitHub-Event` → `x-github-event`).
|
|
284
395
|
- If the action needs a client DB, register it in `initialize()` — it runs before the method.
|
|
285
396
|
- **Worker actions do NOT auto-register `DB_CLIENT`.** The dispatcher registers only
|
|
@@ -324,6 +435,21 @@ Rules: rename **both sides in one PR and one deploy**; grep every repo for the o
|
|
|
324
435
|
commit-before-SQS transaction pattern that the worker relies on.
|
|
325
436
|
|
|
326
437
|
## Change history
|
|
438
|
+
- 2026-08-31 — Documented that **the real time budget for any hand-triggered action is 300s and
|
|
439
|
+
cannot be raised**: the JobScheduler watchdog only honours `CronJobs.maxExecutionTime` through
|
|
440
|
+
`LEFT JOIN … ON wj.cronJobId = cj.id`, and `cronJobId` is NULL for every webhook / `runTask` /
|
|
441
|
+
`curl` job, so they all fall back to `MAX_EXECUTION_TIME_WORKER_ACTION` (300s) — the 3600s SQS
|
|
442
|
+
visibility timeout is not the limit. Recorded that the watchdog does **not** stop the process
|
|
443
|
+
(prod job 1075223: failed at 326s, kept working to 818s, then lost its whole report to MySQL 2006
|
|
444
|
+
on its own result UPDATE). Added the **self-chunking pattern** for over-budget work (re-enqueue
|
|
445
|
+
self with a `resume` cursor; deadline from the top of the job, keyset cursor never OFFSET,
|
|
446
|
+
forward-progress invariant, detail bounded by count *and* bytes, commit the source read before any
|
|
447
|
+
remote call) and the decision **not to retry the chunk hand-off** — `runTask` mints a fresh
|
|
448
|
+
idempotency uuid per call, so a retry forks the chain instead of deduping. Extended the
|
|
449
|
+
nested-parameter gotcha: both dispatch paths cast only the top level, the return trip is
|
|
450
|
+
asymmetric (assoc → `stdClass`, list → array), ints coerce silently, and the fix is a
|
|
451
|
+
`json_encode`/`json_decode(true)` round trip. Surfaced making
|
|
452
|
+
`Netsuite/OpportunityStakeholderBackfill` reportable (PR #157). (ajean)
|
|
327
453
|
- 2026-08-26 — Added two operational gotchas: a new action **does not run on a schedule until its
|
|
328
454
|
`Core.CronJobs` row exists** (schedule is data, not code), and the practice of building a
|
|
329
455
|
**dry-run entry point for a rare-gated path** rather than waiting for it to fire — one found a
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-31
|
|
10
10
|
owners: ["ajean"]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Netsuite/Opportunity.php
|
|
@@ -97,9 +97,67 @@ the only way to populate them.
|
|
|
97
97
|
opportunities affected, and nothing would have surfaced it. Fuzzy matching is opt-in only.
|
|
98
98
|
- **Pacing is 700 ms per REQUEST, not per task** (~85/min against ClickUp's 100/min/token). A task
|
|
99
99
|
costs a variable 1–3 requests, so per-task pacing cannot bound the request rate.
|
|
100
|
+
- **It self-chunks, because a full pass cannot finish inside the 300s watchdog.** The action works
|
|
101
|
+
for `CHUNK_TIME_BUDGET_SECONDS` (180s) and then re-enqueues **itself** via `_Worker::runTask`,
|
|
102
|
+
carrying a keyset cursor and running totals in a `resume` parameter — one `WorkerJobs` row per
|
|
103
|
+
chunk, each labelled `chunk N, run CONTINUES` or `run COMPLETE`. Before this, a full pass was
|
|
104
|
+
stamped failed by the watchdog at 326s, kept working to 818s and then **lost its entire report**
|
|
105
|
+
to a MySQL 2006 on its own result UPDATE. The reusable pattern (and why the chunk hand-off is
|
|
106
|
+
deliberately **not** retried) is in
|
|
107
|
+
[creating-worker-actions](./creating-worker-actions.md#self-chunking-the-pattern-for-work-that-cannot-finish-in-one-job).
|
|
108
|
+
|
|
109
|
+
### Operational baseline (what a real pass looks like)
|
|
110
|
+
|
|
111
|
+
~769–771 task-bearing opportunities → **1,542 field slots** (2 per opportunity). Three production
|
|
112
|
+
passes:
|
|
113
|
+
|
|
114
|
+
| Pass | Date | Chunks / wall time | Filled | Notes |
|
|
115
|
+
|---|---|---|---|---|
|
|
116
|
+
| 1 | 2026-08-28 | 11 chunks, 32m | 1,112 | first live pass, 0 failures |
|
|
117
|
+
| 2 | 2026-08-31 | 5 chunks, 15m | 39 | after 21 new ClickUp options; 1,143 already set |
|
|
118
|
+
| 3 | 2026-08-31 | 6 chunks, ~17m | 191 | after 100 more options; unmatched 194 → 2, coverage **89.0%** |
|
|
119
|
+
|
|
120
|
+
Every pass: **0 failures**, and totals always reconcile to `examined × 2`.
|
|
121
|
+
|
|
122
|
+
- **Chunk throughput depends on mode, sharply:** ~**170** opportunities per chunk read-only vs ~**68**
|
|
123
|
+
when write-heavy, because each filled field adds a paced request. Size expectations by mode, not by
|
|
124
|
+
the last run.
|
|
125
|
+
- **Dry run and live agreed EXACTLY on all five counters** (1112 / 29 / 231 / 166 / 0). That equality
|
|
126
|
+
is a cheap, high-value integrity check — repeat it before every live pass.
|
|
100
127
|
|
|
101
128
|
## Gotchas / known issues
|
|
102
129
|
|
|
130
|
+
- **⚠ THE FUZZY BLIND SPOT — fuzzy-above-floor names are invisible to the digest AND refused by the
|
|
131
|
+
backfill, so those gaps have no work queue and never fill.** The digest's `collectUnmatched()`
|
|
132
|
+
skips anything with a non-null option id
|
|
133
|
+
(`if ($outcome['id'] !== null || !in_array($outcome['reason'], ACTIONABLE_REASONS)) continue;`) —
|
|
134
|
+
and a **fuzzy hit HAS an id**, so a fuzzy name never reaches the PM email and therefore never gets
|
|
135
|
+
written to `Team.ClickUpLabelAliases`. Meanwhile the backfill at `exactOnly:true` **refuses** fuzzy.
|
|
136
|
+
Net effect measured in production: the digest reported **3 outstanding issues while 169 field slots
|
|
137
|
+
were actually empty** — 167 of them fuzzy skips. Coverage plateaus around 89% and nothing surfaces
|
|
138
|
+
the gap. Consequence for whoever is clearing the queue: **assigning every alias in the digest does
|
|
139
|
+
not clear the fuzzy set**, because those names were never queued — verified that
|
|
140
|
+
`Lee County Sheriff's Office`, `Polk County Sheriff's Office` and `Plant City Police` are all
|
|
141
|
+
absent from `ClickUpLabelAliases`. **Suggested fix (not implemented):** the digest should report
|
|
142
|
+
fuzzy-above-floor as its own actionable reason.
|
|
143
|
+
- **Adding ClickUp options creates NEW fuzzy near-misses — the fuzzy count can RISE as gaps are
|
|
144
|
+
filled.** Adding ~121 options moved names out of *unmatched*, but some landed in *fuzzy, skipped*
|
|
145
|
+
rather than *filled*: a customer that previously had **no** candidate now has one scoring 85–95%.
|
|
146
|
+
Observed unmatched **231 → 194 → 2** while fuzzy went **166 → 166 → 167**. So "add more options" is
|
|
147
|
+
not a complete remedy; past a point the remaining work is **aliases, not options**.
|
|
148
|
+
- **An alias is Pass 0 — ahead of exact AND fuzzy — so a WRONG alias lands on tasks, and
|
|
149
|
+
seed-if-empty makes it STICKY.** `resolveLabelOption()` (`Opportunity.php` ~L1298) returns
|
|
150
|
+
confidence `alias` and deliberately bypasses `exactOnly`, which is correct (a human judgement
|
|
151
|
+
outranks machine guessing) but means a mis-entered alias **will** be written. And because the
|
|
152
|
+
backfill only fills **empty** fields, no later run corrects it — someone must clear the field in
|
|
153
|
+
ClickUp by hand. **Operational check worth repeating before every live pass:** audit
|
|
154
|
+
`ClickUpLabelAliases` for mappings whose **first word differs** from the source name. Doing that on
|
|
155
|
+
338 hand-entered aliases surfaced `Hewlett Packard Enterprise → HP` sitting alongside
|
|
156
|
+
`Hewlett-Packard Enterprise-Health and Life Sciences Team → HPE` — HP Inc. and HPE have been
|
|
157
|
+
separate companies since 2015.
|
|
158
|
+
- **The run report lists only the first 50 fuzzy entries.** With 167 fuzzy skips, the other 117 are
|
|
159
|
+
unreviewable *anywhere* — a real reporting gap, and the reason the production fuzzy set had to be
|
|
160
|
+
counted by query rather than read off the report.
|
|
103
161
|
- **`splitCustomerRefName()` must handle composite sub-customer numbers.** The original pattern
|
|
104
162
|
`/^(\d+)\s+(.*)$/` (digits then whitespace) could not parse `"6096:6308 Compass Group"` — the colon
|
|
105
163
|
broke the match, the number stayed in the name, and normalization produced
|
|
@@ -124,6 +182,18 @@ the only way to populate them.
|
|
|
124
182
|
injection before they go into the digest.
|
|
125
183
|
|
|
126
184
|
## Change history
|
|
185
|
+
- 2026-08-31 — **Made the backfill self-chunking and ran three live passes (PR #157, merged).** The
|
|
186
|
+
action now works 180s and re-enqueues itself with a keyset `resume` cursor, one `WorkerJobs` row per
|
|
187
|
+
chunk labelled CONTINUES/COMPLETE — previously a full pass was declared failed by the 300s watchdog
|
|
188
|
+
at 326s, kept working to 818s and lost its whole report to MySQL 2006 (pattern now documented in
|
|
189
|
+
[creating-worker-actions](./creating-worker-actions.md)). Recorded the operational baseline (1,542
|
|
190
|
+
slots; passes filled 1,112 / 39 / 191 to 89.0% coverage, 0 failures, dry-run and live agreeing
|
|
191
|
+
exactly on all five counters) and four gaps found while using it: the **fuzzy blind spot** (fuzzy
|
|
192
|
+
hits have an option id so the digest skips them *and* `exactOnly` refuses them — 3 reported issues
|
|
193
|
+
vs 169 genuinely empty slots, 167 fuzzy), that **adding options can raise the fuzzy count**
|
|
194
|
+
(unmatched 231→2 while fuzzy 166→167), that an **alias is Pass 0 and seed-if-empty makes a wrong
|
|
195
|
+
alias sticky** (audit first-word mismatches — caught `Hewlett Packard Enterprise → HP` vs HPE), and
|
|
196
|
+
that the run report **truncates the fuzzy list at 50 entries**. (ajean)
|
|
127
197
|
- 2026-08-26 — **Built the client vs end-customer split (TRUE-81010, PR #142, merged).** Stakeholders
|
|
128
198
|
now receives the consolidated parent (the client, NS `custentity10`) and a new **End Customer**
|
|
129
199
|
field receives the opportunity `entity`; resolution is local via
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-31
|
|
10
10
|
owners: ["dfranks", "kyalamarthi", "ajean"]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Netsuite.php
|
|
@@ -31,7 +31,10 @@ files:
|
|
|
31
31
|
related:
|
|
32
32
|
- ./netsuite-opportunity-client-labels.md
|
|
33
33
|
- ./netsuite-salesorder-open-orders-sync.md
|
|
34
|
+
- ./clickup-project-routing.md
|
|
34
35
|
- ../architecture.md
|
|
36
|
+
- ../../../../1.0/apps/webhook/features/netsuite-clickup-opportunity-sync.md
|
|
37
|
+
- ../../../../standalone/apps/claude/workflows/mcp-tool-usage.md
|
|
35
38
|
---
|
|
36
39
|
|
|
37
40
|
## Summary
|
|
@@ -119,6 +122,23 @@ the "CU→NS direction" section.
|
|
|
119
122
|
(text = string compare; Presales Lead users field = add-only, matches when already assigned) and
|
|
120
123
|
`normalizeDescription()`.
|
|
121
124
|
3. **No match → `createTask()`**: POST to list `901111987449`, `custom_item_id` = `1009`.
|
|
125
|
+
**That is only the task's list at BIRTH** — something outside the worker moves new opportunity
|
|
126
|
+
tasks to a different **home** list, which makes a healthy sync look dead. See
|
|
127
|
+
*⚠ A healthy sync reads as dead* under Triage below before concluding tasks aren't being created.
|
|
128
|
+
|
|
129
|
+
**The Presales-Lead gate DELAYS a task; it does not permanently lose one.** The natural (and wrong)
|
|
130
|
+
reading is *"no lead at creation time ⇒ that opportunity never gets a task."* Because the gate runs
|
|
131
|
+
on **both** `post()` and `put()`, and the claim stays open while
|
|
132
|
+
`Forecast.Opportunities.clickupTaskId` is still NULL, whichever later edit webhook first sees a
|
|
133
|
+
non-empty Presales Lead wins the claim and creates the task **then** — however old the opportunity
|
|
134
|
+
is. Verified live 2026-08-31: NS internalId **6682331** (opportunity **71113**, "6519 Hydro Resources
|
|
135
|
+
— AI Training", `Forecast.Opportunities.id` 70133 — an *old* opportunity) received task
|
|
136
|
+
`868kyqxq2` from a `Netsuite/Opportunity/put`, verdict
|
|
137
|
+
`task created (stakeholder "Hydro Resources"; end customer "Hydro Resources")`. That is a **first**
|
|
138
|
+
claim on a never-claimed opportunity, so it does **not** contradict the rule that once
|
|
139
|
+
`clickupTaskId` holds a real id every later webhook returns `skipped (already claimed)` (see
|
|
140
|
+
*Consequence: `updateTask()` is unreachable* below). So when triaging a backlog of
|
|
141
|
+
`skipped (no presales lead)`, read it as **"queued, awaiting a lead"**, not "lost".
|
|
122
142
|
|
|
123
143
|
Shared helpers: `buildCustomFields()` (the field array, used by both create and update),
|
|
124
144
|
`resolvePresalesUserId()` (cached `Client_True.Users.c_clickupUserIdentifier` lookup → else a
|
|
@@ -226,6 +246,84 @@ reaches **none** of the 761 existing tasks — only a separate backfill action r
|
|
|
226
246
|
says nothing about the claim interaction, so reading the constant alone gives you the wrong model of
|
|
227
247
|
what enabling it would do.
|
|
228
248
|
|
|
249
|
+
## Triage: is the opportunity sync working? (`WorkerJobs.output` verdicts)
|
|
250
|
+
|
|
251
|
+
**Start here** whenever anyone reports *"no new opportunity tasks in ClickUp."* One query answers it
|
|
252
|
+
in seconds — and, importantly, **with no NetSuite or ClickUp access at all.** That matters: the
|
|
253
|
+
NetSuite MCP connector needs interactive OAuth and is routinely unavailable in a session (it was on
|
|
254
|
+
2026-08-31), which blocks the two NetSuite-side checks this doc otherwise sends you to — the
|
|
255
|
+
`scriptdeployment` status/audience query and the API Message Queue
|
|
256
|
+
`custrecordcustrecord_amq_errormsg` inspection. The verdict census below is a genuine **end-to-end
|
|
257
|
+
substitute** for "is it working".
|
|
258
|
+
|
|
259
|
+
Every `Netsuite/Opportunity/post|put` job writes a JSON result into `Core.WorkerJobs.output`:
|
|
260
|
+
|
|
261
|
+
```json
|
|
262
|
+
{"db":"created|updated opportunity <internalId> (id=<forecastId>, items=<n>)","clickup":"<verdict>"}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**In practice there are only three `clickup` verdicts.** Census over 2026-08-20 → 2026-08-31
|
|
266
|
+
(1,218 jobs, prod):
|
|
267
|
+
|
|
268
|
+
| Verdict | Count | Share |
|
|
269
|
+
|---|---|---|
|
|
270
|
+
| `skipped (no presales lead)` | 1,047 | 86% |
|
|
271
|
+
| `skipped (already claimed)` | 150 | 12% |
|
|
272
|
+
| `task created` (optionally + the stakeholder/end-customer label suffix) | 21 | 1.7% |
|
|
273
|
+
|
|
274
|
+
- **The healthy baseline is ~2% of opportunity events producing a ClickUp task.** A **high
|
|
275
|
+
`no presales lead` share is NOT a fault** — it is the normal shape of the traffic, and those are
|
|
276
|
+
queued rather than lost (see the gate-delay note under *ClickUp opportunity task*). **Zero tasks
|
|
277
|
+
created on a business day** is the signal worth chasing.
|
|
278
|
+
- Zero error verdicts and zero `(label lookup failed)` in that window — independent confirmation
|
|
279
|
+
that the 2026-08-26 `logged()` NOT-NULL-column fix (PRs #150 + #151) is holding in production.
|
|
280
|
+
- **Query gotcha:** the `toga-db` MCP validator rejects the literal keyword `UPDATE` **even inside a
|
|
281
|
+
string literal or a column alias**, so match the `skipped update (update disabled)` verdict with
|
|
282
|
+
`LIKE '%disabled%'` and alias the column `skippedDisabled`, never `updateDisabled`. See
|
|
283
|
+
[MCP Tool Usage](../../../../standalone/apps/claude/workflows/mcp-tool-usage.md).
|
|
284
|
+
|
|
285
|
+
### ⚠ A healthy sync reads as dead: new tasks do NOT stay in "Presales Qualification"
|
|
286
|
+
|
|
287
|
+
**This is the first thing to check when someone says "no opportunity tasks have been created."**
|
|
288
|
+
`maybeCreateClickupTask()` only ever POSTs to list `901111987449` ("Presales Qualification"), and
|
|
289
|
+
ClickUp's v2 API **cannot** alter a task's home list (`400 TASK_035`) — **so the worker cannot be
|
|
290
|
+
the mover.** Yet observed live 2026-08-31, worker-created tasks report their **home** list as
|
|
291
|
+
`901111987451` ("Quote/Proposal/SOW/MSA"), in folder `90117060687` ("Opportunities"), space
|
|
292
|
+
`90113928591` ("Opportunity & RFP Hub") — same folder and space, different list.
|
|
293
|
+
|
|
294
|
+
| Task | Opportunity (NS internalId) | Created (CDT) | Current home list |
|
|
295
|
+
|---|---|---|---|
|
|
296
|
+
| `868kyq7e1` | 75850 (7435817) | 2026-08-31 08:26 | Quote/Proposal/SOW/MSA |
|
|
297
|
+
| `868kyqxq2` | 71113 (6682331) | 2026-08-31 09:09 | Quote/Proposal/SOW/MSA |
|
|
298
|
+
| `868kxtf1n` | 75809 (7429824) | 2026-08-27 18:02 | Quote/Proposal/SOW/MSA |
|
|
299
|
+
| `868kxgexk` | 75791 (7427497) | 2026-08-27 10:25 | **still Presales Qualification** |
|
|
300
|
+
|
|
301
|
+
**Consequence: a developer or PM watching the "Presales Qualification" list will believe the sync is
|
|
302
|
+
dead while it is working.** Verify by **task id**, or across the whole **Opportunities folder** —
|
|
303
|
+
never by that one list.
|
|
304
|
+
|
|
305
|
+
**Open question — the mover is NOT identified.** It is not cleanly stage-driven: `868kxtf1n` and
|
|
306
|
+
`868kxgexk` were created the same day, both at Stage 25%, and only one moved. So it is either a
|
|
307
|
+
ClickUp native **Automation** with a condition not visible through the API, or a **human** moving
|
|
308
|
+
them. Do not assert a cause. Either way it is consistent with the documented hybrid split —
|
|
309
|
+
home-list movement is owned by ClickUp native Automations, secondary multi-list membership by the
|
|
310
|
+
worker: see
|
|
311
|
+
[ClickUp Project & Opportunity Multi-List Routing](./clickup-project-routing.md).
|
|
312
|
+
|
|
313
|
+
### Watch-item: 2026-08-28 — 74 opportunity events, 0 tasks created (unconfirmed)
|
|
314
|
+
|
|
315
|
+
Tasks created per day in that window: Aug 24 = 1, Aug 25 = 7, Aug 26 = 2, Aug 27 = 4,
|
|
316
|
+
**Aug 28 = 0**, Aug 29–30 (weekend) = 0, Aug 31 = 2. Aug 28 was a **business day with 74 opportunity
|
|
317
|
+
jobs, 72 of them `skipped (no presales lead)`, and zero tasks created** — the only anomalous weekday.
|
|
318
|
+
|
|
319
|
+
**Cause NOT confirmed.** On a single data point it is just as likely that nobody assigned presales
|
|
320
|
+
leads that Friday. *If it recurs*, the first candidate to check is NetSuite scheduled script **2412**
|
|
321
|
+
("Update Opp Status / % to Close / Presales Lead"), which auto-assigns the Presales Lead each morning
|
|
322
|
+
and already has its own regression history — see
|
|
323
|
+
[1.0 webhook: NetSuite → ClickUp opportunity sync](../../../../1.0/apps/webhook/features/netsuite-clickup-opportunity-sync.md).
|
|
324
|
+
Diagnostic: re-run the per-day verdict census, and if the leads really are unset in NetSuite, check
|
|
325
|
+
2412's deployment status/audience before reading any PHP.
|
|
326
|
+
|
|
229
327
|
## Data model
|
|
230
328
|
|
|
231
329
|
Writes `Forecast.Opportunities` (header) + `Forecast.OpportunityItems` (children), faithful to
|
|
@@ -554,7 +652,20 @@ deprecated** for production opportunity code.
|
|
|
554
652
|
blocked on the Aaron stakeholder decision noted above.
|
|
555
653
|
|
|
556
654
|
## Change history
|
|
557
|
-
|
|
655
|
+
- 2026-08-31 — **Triage knowledge from a read-only "is the opportunity sync dead?" investigation — no
|
|
656
|
+
code changed.** Added a *Triage* section: the `Core.WorkerJobs.output` `clickup` verdict census is
|
|
657
|
+
the fast, NetSuite-free answer, and it has exactly **three** verdicts in practice, with a healthy
|
|
658
|
+
baseline of only **~2% of opportunity events producing a task** (86% are `skipped (no presales
|
|
659
|
+
lead)`, which is normal traffic shape, not a fault). Recorded the visibility trap that made a
|
|
660
|
+
working sync look dead: the worker only ever POSTs to list `901111987449` "Presales Qualification"
|
|
661
|
+
and **cannot** move a home list (TASK_035), yet new tasks are found homed in `901111987451`
|
|
662
|
+
"Quote/Proposal/SOW/MSA" — mover **not identified** (not cleanly stage-driven; ClickUp Automation
|
|
663
|
+
or a human), so verify by task id/folder, never by that one list. Corrected the reading of the
|
|
664
|
+
Presales-Lead gate: it **delays** a task, it does not lose one — a later edit webhook wins the
|
|
665
|
+
still-open claim whenever the lead is finally set (proven live on opportunity 71113 / internalId
|
|
666
|
+
6682331). Logged 2026-08-28 (74 events, 0 tasks) as an **unconfirmed** watch-item with NS script
|
|
667
|
+
2412 as the candidate to check on recurrence. Also confirmed the 2026-08-26 `logged()` fix is
|
|
668
|
+
holding (zero error / `(label lookup failed)` verdicts). (ajean)
|
|
558
669
|
- 2026-08-27 — **Corrected the "NetSuite calls aren't logged to `Logs.Api`" gotcha — they are.** No
|
|
559
670
|
`setLogging` call exists in `_underscore/Component/Api/Netsuite/Netsuite.php`, so `_ApiRequest`'s
|
|
560
671
|
`$isLoggingEnabled = true` default holds and every NetSuite call (read-only SuiteQL included) writes a
|
|
@@ -704,3 +815,9 @@ deprecated** for production opportunity code.
|
|
|
704
815
|
## Related docs
|
|
705
816
|
- `2.0/apps/worker2/architecture.md` — the WorkerJobs/SQS/Lambda model and always-HTTP-200 worker.
|
|
706
817
|
- `2.0/apps/_underscore/architecture.md` — the `_Database::register()` lazy-transaction gotcha.
|
|
818
|
+
- [ClickUp Project & Opportunity Multi-List Routing](./clickup-project-routing.md) — why home-list
|
|
819
|
+
movement is owned by ClickUp native Automations and not by any worker.
|
|
820
|
+
- [1.0 webhook: NetSuite → ClickUp opportunity sync](../../../../1.0/apps/webhook/features/netsuite-clickup-opportunity-sync.md)
|
|
821
|
+
— NetSuite scheduled script 2412, which auto-assigns the Presales Lead that gates task creation.
|
|
822
|
+
- [MCP Tool Usage](../../../../standalone/apps/claude/workflows/mcp-tool-usage.md) — the `toga-db`
|
|
823
|
+
query traps hit while running the verdict census.
|
|
@@ -6,7 +6,7 @@ project: Claude Harness
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: workflow
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-31
|
|
10
10
|
owners: ["jcardinal", "tcox", "ajean"]
|
|
11
11
|
files:
|
|
12
12
|
- claude/.claude/skills/kickoff/SKILL.md
|
|
@@ -141,8 +141,10 @@ trust `Core.DatabaseHosts`, not old migrations.)
|
|
|
141
141
|
**⚠ The write-blocker is a naive string scan — it rejects `UPDATE`/`REPLACE` even inside a string
|
|
142
142
|
literal.** `toga_query` refuses the whole statement if those words appear anywhere in it, so a
|
|
143
143
|
perfectly read-only query like
|
|
144
|
-
`… WHERE output LIKE '%skipped update%'` fails validation.
|
|
145
|
-
|
|
144
|
+
`… WHERE output LIKE '%skipped update%'` fails validation. **A column alias trips it too** —
|
|
145
|
+
`… AS updateDisabled` is rejected on the same scan, so the alias has to be renamed
|
|
146
|
+
(`skippedDisabled`), not just the predicate. Work around it by matching on a fragment that avoids the
|
|
147
|
+
keyword (`LIKE '%disabled%'`, `LIKE '%skipped%'`), or by pulling the rows and filtering client-side —
|
|
146
148
|
don't conclude the data isn't there.
|
|
147
149
|
|
|
148
150
|
**Production sessions render in US/Central (UTC−5); most other timestamps you're comparing are
|
|
@@ -195,6 +197,9 @@ Schema names are **case-sensitive** — copy the exact value from `toga_list_sch
|
|
|
195
197
|
and why. Database → what is actually true right now.
|
|
196
198
|
|
|
197
199
|
## Change history
|
|
200
|
+
- 2026-08-31 — Extended the `toga_query` write-blocker trap: the naive keyword scan also rejects a
|
|
201
|
+
**column alias** containing `UPDATE` (`… AS updateDisabled`), not just a string literal — rename the
|
|
202
|
+
alias as well as the predicate. Hit while censusing `WorkerJobs.output` sync verdicts. (ajean)
|
|
198
203
|
- 2026-08-26 — Added two `toga-db` query traps: the write-blocker is a **naive string scan** that
|
|
199
204
|
rejects any statement containing `UPDATE`/`REPLACE` **even inside a string literal** (so
|
|
200
205
|
`LIKE '%skipped update%'` fails validation on a read-only SELECT), and **production DB sessions
|
package/package.json
CHANGED
|
@@ -22,6 +22,29 @@ thinking, your plans, and your final answers. This is a hard team rule, not a st
|
|
|
22
22
|
6. **Stay technical, stay exact.** Keep the real detail — file names, error text, exact steps.
|
|
23
23
|
Short does not mean vague. Be brief AND precise.
|
|
24
24
|
|
|
25
|
+
## When a human must do something — always use a numbered list
|
|
26
|
+
|
|
27
|
+
Any time a person has to take action — deploy something, run SQL, register a queue, flip a
|
|
28
|
+
config, restart a service, anything manual — you MUST give it as a clear, numbered ordered
|
|
29
|
+
list of steps. One action per step.
|
|
30
|
+
|
|
31
|
+
- Never bury a human action inside a paragraph. People miss steps that hang off the end of a
|
|
32
|
+
sentence, and a missed deploy or SQL step breaks production.
|
|
33
|
+
- Put the list under a short heading like **"What you need to do:"** so it stands out.
|
|
34
|
+
- One step = one action. If a step has two actions, split it into two steps.
|
|
35
|
+
- Put the steps in the exact order they must happen. For SQL or deploys, that means the file
|
|
36
|
+
and the order first (which file, which environment, in what sequence).
|
|
37
|
+
- Keep each step short: the action, the target, and how to know it worked.
|
|
38
|
+
- If nothing manual is needed, say that in one line — do not invent steps.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
What you need to do:
|
|
42
|
+
1. Run `2026-08-31a - AddColumn.sql` on the Core database (prod).
|
|
43
|
+
2. Then run `2026-08-31b - Seed.sql` on the same database.
|
|
44
|
+
3. Deploy worker2 to Elastic Beanstalk.
|
|
45
|
+
4. Confirm the WorkerJobs table shows the new column.
|
|
46
|
+
```
|
|
47
|
+
|
|
25
48
|
## Check before you send
|
|
26
49
|
|
|
27
50
|
- Can I cut half the words and keep the meaning? Cut them.
|